Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9572ce9d |
No files matched your search
@@ -4,6 +4,7 @@
|
|||||||
<OutputType Condition="'$(RuntimeIdentifier)' == 'win-x64'">WinExe</OutputType>
|
<OutputType Condition="'$(RuntimeIdentifier)' == 'win-x64'">WinExe</OutputType>
|
||||||
<OutputType Condition="'$(RuntimeIdentifier)' != 'win-x64'">Exe</OutputType>
|
<OutputType Condition="'$(RuntimeIdentifier)' != 'win-x64'">Exe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Version>1.0.1</Version>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
|||||||
+142
-140
@@ -213,6 +213,7 @@ public sealed class MergeOutput
|
|||||||
{
|
{
|
||||||
public List<string> Columns { get; init; } = new();
|
public List<string> Columns { get; init; } = new();
|
||||||
public List<string[]> Rows { get; init; } = new();
|
public List<string[]> Rows { get; init; } = new();
|
||||||
|
public int[] SecondaryColumnMap { get; init; } = Array.Empty<int>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TableMatcher
|
public static class TableMatcher
|
||||||
@@ -296,7 +297,7 @@ public static class TableMatcher
|
|||||||
|
|
||||||
private static bool IsEmptyParts(string[] parts) => parts.All(string.IsNullOrEmpty);
|
private static bool IsEmptyParts(string[] parts) => parts.All(string.IsNullOrEmpty);
|
||||||
|
|
||||||
private static string JoinKey(string[] parts) => string.Join("\u00a6", parts);
|
private static string JoinKey(string[] parts) => string.Concat(parts.Select(part => $"{part.Length}:{part};"));
|
||||||
|
|
||||||
private static bool PartsContain(string[] a, string[] b)
|
private static bool PartsContain(string[] a, string[] b)
|
||||||
{
|
{
|
||||||
@@ -360,21 +361,24 @@ public static class TableMatcher
|
|||||||
|
|
||||||
private sealed class Candidate
|
private sealed class Candidate
|
||||||
{
|
{
|
||||||
public string[]? BRow;
|
public List<(string[] Row, double Score)> BRows = new();
|
||||||
public string Type = "";
|
public string Type = "";
|
||||||
public double Score;
|
|
||||||
public int BCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Candidate> MatchMain(TableData main, TableData secondary, MatchOptions o, out List<string[]> onlySecondary)
|
private static List<Candidate> MatchMain(TableData main, TableData secondary, MatchOptions o, out List<string[]> onlySecondary)
|
||||||
{
|
{
|
||||||
var mainIdx = KeyIndexes(o.MainUsed);
|
var mainIdx = KeyIndexes(o.MainUsed);
|
||||||
var secIdx = KeyIndexes(o.SecondaryUsed);
|
var secIdx = KeyIndexes(o.SecondaryUsed);
|
||||||
int keyCount = Math.Min(mainIdx.Count, secIdx.Count);
|
if (mainIdx.Count != secIdx.Count)
|
||||||
|
throw new ArgumentException("Выберите одинаковое число ключевых колонок в обеих таблицах.");
|
||||||
|
int keyCount = mainIdx.Count;
|
||||||
onlySecondary = new List<string[]>();
|
onlySecondary = new List<string[]>();
|
||||||
|
|
||||||
if (keyCount == 0)
|
if (keyCount == 0)
|
||||||
return main.Rows.Select(_ => (Candidate?)null).Cast<Candidate>().ToList();
|
{
|
||||||
|
onlySecondary.AddRange(secondary.Rows);
|
||||||
|
return main.Rows.Select(_ => new Candidate()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
var token = o.Token;
|
var token = o.Token;
|
||||||
bool or = o.UseOr;
|
bool or = o.UseOr;
|
||||||
@@ -388,34 +392,38 @@ public static class TableMatcher
|
|||||||
foreach (var row in main.Rows)
|
foreach (var row in main.Rows)
|
||||||
{
|
{
|
||||||
var parts = RowParts(row, mainIdx, keyCount, o);
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
if (!IsEmptyParts(parts)) mainKeys.Add(JoinKey(parts));
|
if (parts.All(part => !string.IsNullOrEmpty(part))) mainKeys.Add(JoinKey(parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
var bByKey = new Dictionary<string, (string[] Row, int Count)>(StringComparer.Ordinal);
|
var bByKey = new Dictionary<string, List<string[]>>(StringComparer.Ordinal);
|
||||||
foreach (var row in secondary.Rows)
|
foreach (var row in secondary.Rows)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, secIdx, keyCount, o);
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
if (IsEmptyParts(parts)) continue;
|
if (parts.Any(string.IsNullOrEmpty)) continue;
|
||||||
var k = JoinKey(parts);
|
var k = JoinKey(parts);
|
||||||
if (bByKey.TryGetValue(k, out var cur))
|
if (bByKey.TryGetValue(k, out var rows))
|
||||||
bByKey[k] = (cur.Row, cur.Count + 1);
|
rows.Add(row);
|
||||||
else
|
else
|
||||||
bByKey[k] = (row, 1);
|
bByKey[k] = new List<string[]> { row };
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var row in main.Rows)
|
foreach (var row in main.Rows)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, mainIdx, keyCount, o);
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
if (IsEmptyParts(parts))
|
if (parts.Any(string.IsNullOrEmpty))
|
||||||
{
|
{
|
||||||
candidates.Add(new Candidate());
|
candidates.Add(new Candidate());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (bByKey.TryGetValue(JoinKey(parts), out var hit))
|
if (bByKey.TryGetValue(JoinKey(parts), out var hit))
|
||||||
{
|
{
|
||||||
candidates.Add(new Candidate { BRow = hit.Row, Type = "точное", Score = 100, BCount = hit.Count });
|
candidates.Add(new Candidate
|
||||||
|
{
|
||||||
|
Type = "точное",
|
||||||
|
BRows = hit.Select(row => (row, 100.0)).ToList()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -427,7 +435,7 @@ public static class TableMatcher
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, secIdx, keyCount, o);
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
if (!IsEmptyParts(parts) && !mainKeys.Contains(JoinKey(parts)))
|
if (parts.Any(string.IsNullOrEmpty) || !mainKeys.Contains(JoinKey(parts)))
|
||||||
onlySecondary.Add(row);
|
onlySecondary.Add(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,21 +451,21 @@ public static class TableMatcher
|
|||||||
if (parts[j].Length > 0) mainSets[j].Add(parts[j]);
|
if (parts[j].Length > 0) mainSets[j].Add(parts[j]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var bByVal = new Dictionary<string, (string[] Row, int Count)>[keyCount];
|
var bByVal = new Dictionary<string, List<int>>[keyCount];
|
||||||
for (int j = 0; j < keyCount; j++) bByVal[j] = new Dictionary<string, (string[] Row, int Count)>(StringComparer.Ordinal);
|
for (int j = 0; j < keyCount; j++) bByVal[j] = new Dictionary<string, List<int>>(StringComparer.Ordinal);
|
||||||
foreach (var row in secondary.Rows)
|
for (int rowIndex = 0; rowIndex < secondary.Rows.Count; rowIndex++)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, secIdx, keyCount, o);
|
var parts = RowParts(secondary.Rows[rowIndex], secIdx, keyCount, o);
|
||||||
if (IsEmptyParts(parts)) continue;
|
if (IsEmptyParts(parts)) continue;
|
||||||
for (int j = 0; j < keyCount; j++)
|
for (int j = 0; j < keyCount; j++)
|
||||||
{
|
{
|
||||||
if (parts[j].Length == 0) continue;
|
if (parts[j].Length == 0) continue;
|
||||||
var v = parts[j];
|
var v = parts[j];
|
||||||
if (bByVal[j].TryGetValue(v, out var cur))
|
if (bByVal[j].TryGetValue(v, out var rowIndexes))
|
||||||
bByVal[j][v] = (cur.Row, cur.Count + 1);
|
rowIndexes.Add(rowIndex);
|
||||||
else
|
else
|
||||||
bByVal[j][v] = (row, 1);
|
bByVal[j][v] = new List<int> { rowIndex };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,28 +478,28 @@ public static class TableMatcher
|
|||||||
candidates.Add(new Candidate());
|
candidates.Add(new Candidate());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
string[]? bestRow = null;
|
var matchedRows = new HashSet<int>();
|
||||||
int bestCount = 0;
|
|
||||||
for (int j = 0; j < keyCount; j++)
|
for (int j = 0; j < keyCount; j++)
|
||||||
{
|
{
|
||||||
if (parts[j].Length == 0) continue;
|
if (parts[j].Length == 0) continue;
|
||||||
if (bByVal[j].TryGetValue(parts[j], out var hit) && hit.Count > bestCount)
|
if (bByVal[j].TryGetValue(parts[j], out var rowIndexes))
|
||||||
{
|
foreach (var rowIndex in rowIndexes) matchedRows.Add(rowIndex);
|
||||||
bestRow = hit.Row;
|
|
||||||
bestCount = hit.Count;
|
|
||||||
}
|
}
|
||||||
}
|
var candidate = new Candidate { Type = "точное" };
|
||||||
if (bestRow is not null)
|
foreach (var rowIndex in matchedRows.OrderBy(index => index))
|
||||||
candidates.Add(new Candidate { BRow = bestRow, Type = "точное", Score = 100, BCount = bestCount });
|
candidate.BRows.Add((secondary.Rows[rowIndex], 100.0));
|
||||||
else
|
candidates.Add(candidate);
|
||||||
candidates.Add(new Candidate());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var row in secondary.Rows)
|
foreach (var row in secondary.Rows)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, secIdx, keyCount, o);
|
var parts = RowParts(row, secIdx, keyCount, o);
|
||||||
if (IsEmptyParts(parts)) continue;
|
if (IsEmptyParts(parts))
|
||||||
|
{
|
||||||
|
onlySecondary.Add(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
bool matched = false;
|
bool matched = false;
|
||||||
for (int j = 0; j < keyCount; j++)
|
for (int j = 0; j < keyCount; j++)
|
||||||
{
|
{
|
||||||
@@ -518,15 +526,13 @@ public static class TableMatcher
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, mainIdx, keyCount, o);
|
var parts = RowParts(row, mainIdx, keyCount, o);
|
||||||
if (IsEmptyParts(parts))
|
if (IsEmptyParts(parts) || (!or && parts.Any(string.IsNullOrEmpty)))
|
||||||
{
|
{
|
||||||
candidates.Add(new Candidate());
|
candidates.Add(new Candidate());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string[]? bestRow = null;
|
var candidate = new Candidate { Type = o.Kind == MatchKind.Partial ? "частичное" : "нечёткое" };
|
||||||
double bestScore = 0;
|
|
||||||
int hitCount = 0;
|
|
||||||
|
|
||||||
if (o.Kind == MatchKind.Partial)
|
if (o.Kind == MatchKind.Partial)
|
||||||
{
|
{
|
||||||
@@ -536,13 +542,9 @@ public static class TableMatcher
|
|||||||
bool ok = or ? AnyColContains(parts, b.Parts) : PartsContain(parts, b.Parts);
|
bool ok = or ? AnyColContains(parts, b.Parts) : PartsContain(parts, b.Parts);
|
||||||
if (!ok) continue;
|
if (!ok) continue;
|
||||||
hits[i] = true;
|
hits[i] = true;
|
||||||
hitCount++;
|
candidate.BRows.Add((b.Row, 100.0));
|
||||||
if (bestRow is null) bestRow = b.Row;
|
|
||||||
}
|
}
|
||||||
if (bestRow is not null)
|
candidates.Add(candidate);
|
||||||
candidates.Add(new Candidate { BRow = bestRow, BCount = hitCount, Score = 100, Type = "частичное" });
|
|
||||||
else
|
|
||||||
candidates.Add(new Candidate());
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -550,28 +552,15 @@ public static class TableMatcher
|
|||||||
for (int i = 0; i < secRows.Count; i++)
|
for (int i = 0; i < secRows.Count; i++)
|
||||||
{
|
{
|
||||||
var b = secRows[i];
|
var b = secRows[i];
|
||||||
|
if (!or && b.Parts.Any(string.IsNullOrEmpty)) continue;
|
||||||
double sim = ColumnSim(parts, b.Parts, or);
|
double sim = ColumnSim(parts, b.Parts, or);
|
||||||
if (sim >= threshold)
|
if (sim >= threshold)
|
||||||
{
|
{
|
||||||
hits[i] = true;
|
hits[i] = true;
|
||||||
hitCount++;
|
candidate.BRows.Add((b.Row, Math.Round(sim * 100, 1)));
|
||||||
if (sim > bestScore)
|
|
||||||
{
|
|
||||||
bestScore = sim;
|
|
||||||
bestRow = b.Row;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
candidates.Add(candidate);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,21 +585,24 @@ public static class TableMatcher
|
|||||||
{
|
{
|
||||||
o.Token.ThrowIfCancellationRequested();
|
o.Token.ThrowIfCancellationRequested();
|
||||||
var c = candidates[i];
|
var c = candidates[i];
|
||||||
if (c.BRow is null)
|
if (c.BRows.Count == 0)
|
||||||
{
|
{
|
||||||
onlyMain.Add(main.Rows[i]);
|
onlyMain.Add(main.Rows[i]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
matches.Add(main.Rows[i]);
|
matches.Add(main.Rows[i]);
|
||||||
|
foreach (var matched in c.BRows)
|
||||||
|
{
|
||||||
pairs.Add(new MatchPair
|
pairs.Add(new MatchPair
|
||||||
{
|
{
|
||||||
RowA = main.Rows[i],
|
RowA = main.Rows[i],
|
||||||
RowB = c.BRow,
|
RowB = matched.Row,
|
||||||
Type = c.Type,
|
Type = c.Type,
|
||||||
Score = c.Score,
|
Score = matched.Score,
|
||||||
BCount = c.BCount
|
BCount = c.BRows.Count
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new MatchResult
|
return new MatchResult
|
||||||
{
|
{
|
||||||
@@ -633,65 +625,98 @@ public static class TableMatcher
|
|||||||
Kind = partial ? MatchKind.Partial : MatchKind.Exact
|
Kind = partial ? MatchKind.Partial : MatchKind.Exact
|
||||||
});
|
});
|
||||||
|
|
||||||
public static MergeOutput Merge(TableData main, TableData secondary, MatchOptions o, bool removeIdenticalColumns = true)
|
public static MergeOutput Merge(TableData main, TableData secondary, MatchOptions o, bool mergeColumnsByHeader = true)
|
||||||
{
|
{
|
||||||
var candidates = MatchMain(main, secondary, o, out _);
|
var candidates = MatchMain(main, secondary, o, out _);
|
||||||
|
|
||||||
var columns = new List<string>(main.Columns);
|
var columns = new List<string>(main.Columns);
|
||||||
var extraCols = new List<int>();
|
var secondaryColumnMap = new int[secondary.Columns.Count];
|
||||||
|
var commonMainColumn = Enumerable.Repeat(-1, secondary.Columns.Count).ToArray();
|
||||||
|
var usedMainColumns = new HashSet<int>();
|
||||||
|
var conflictingSecondaryColumns = new bool[secondary.Columns.Count];
|
||||||
|
|
||||||
if (removeIdenticalColumns)
|
if (mergeColumnsByHeader)
|
||||||
{
|
{
|
||||||
for (int j = 0; j < secondary.Columns.Count; j++)
|
for (int j = 0; j < secondary.Columns.Count; j++)
|
||||||
{
|
{
|
||||||
if (columns.Contains(secondary.Columns[j]))
|
commonMainColumn[j] = Enumerable.Range(0, main.Columns.Count)
|
||||||
|
.FirstOrDefault(c => !usedMainColumns.Contains(c) &&
|
||||||
|
string.Equals(main.Columns[c].Trim(), secondary.Columns[j].Trim(), StringComparison.OrdinalIgnoreCase), -1);
|
||||||
|
if (commonMainColumn[j] >= 0) usedMainColumns.Add(commonMainColumn[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If same-named fields contain different non-empty values in any matched
|
||||||
|
// pair, keep the B value in its own column instead of silently discarding it.
|
||||||
|
for (int i = 0; i < candidates.Count && i < main.RowCount; i++)
|
||||||
|
{
|
||||||
|
foreach (var match in candidates[i].BRows)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < secondary.Columns.Count; j++)
|
||||||
|
{
|
||||||
|
int a = commonMainColumn[j];
|
||||||
|
if (a < 0) continue;
|
||||||
|
string av = a < main.Rows[i].Length ? main.Rows[i][a] : "";
|
||||||
|
string bv = j < match.Row.Length ? match.Row[j] : "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(av) && !string.IsNullOrWhiteSpace(bv) &&
|
||||||
|
!string.Equals(av, bv, StringComparison.Ordinal))
|
||||||
|
conflictingSecondaryColumns[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int AddUniqueColumn(string header)
|
||||||
|
{
|
||||||
|
string baseHeader = string.IsNullOrWhiteSpace(header) ? "Колонка B" : header;
|
||||||
|
string unique = baseHeader;
|
||||||
|
int suffix = 2;
|
||||||
|
while (columns.Contains(unique, StringComparer.OrdinalIgnoreCase))
|
||||||
|
unique = $"{baseHeader} ({suffix++})";
|
||||||
|
columns.Add(unique);
|
||||||
|
return columns.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int j = 0; j < secondary.Columns.Count; j++)
|
||||||
|
{
|
||||||
|
if (mergeColumnsByHeader && commonMainColumn[j] >= 0 && !conflictingSecondaryColumns[j])
|
||||||
|
{
|
||||||
|
secondaryColumnMap[j] = commonMainColumn[j];
|
||||||
continue;
|
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)
|
string header = secondary.Columns[j];
|
||||||
{
|
if (!mergeColumnsByHeader || commonMainColumn[j] >= 0 || columns.Contains(header, StringComparer.OrdinalIgnoreCase))
|
||||||
var merged = new string[columns.Count];
|
header = "B: " + header;
|
||||||
for (int c = 0; c < main.Columns.Count; c++)
|
secondaryColumnMap[j] = AddUniqueColumn(header);
|
||||||
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[]>();
|
var rows = new List<string[]>();
|
||||||
int n = Math.Min(candidates.Count, main.RowCount);
|
for (int i = 0; i < candidates.Count && i < main.RowCount; i++)
|
||||||
for (int i = 0; i < n; i++)
|
|
||||||
{
|
{
|
||||||
o.Token.ThrowIfCancellationRequested();
|
o.Token.ThrowIfCancellationRequested();
|
||||||
var c = candidates[i];
|
foreach (var match in candidates[i].BRows)
|
||||||
if (c.BRow is not null)
|
{
|
||||||
rows.Add(BuildMerged(main.Rows[i], c.BRow));
|
var merged = new string[columns.Count];
|
||||||
|
Array.Fill(merged, "");
|
||||||
|
for (int c = 0; c < main.Columns.Count; c++)
|
||||||
|
if (c < main.Rows[i].Length) merged[c] = main.Rows[i][c];
|
||||||
|
for (int j = 0; j < secondaryColumnMap.Length; j++)
|
||||||
|
{
|
||||||
|
string bv = j < match.Row.Length ? match.Row[j] : "";
|
||||||
|
int destination = secondaryColumnMap[j];
|
||||||
|
if (commonMainColumn[j] >= 0 && !conflictingSecondaryColumns[j])
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(merged[destination])) merged[destination] = bv;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
merged[destination] = bv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Add(merged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new MergeOutput { Columns = columns, Rows = rows };
|
return new MergeOutput { Columns = columns, Rows = rows, SecondaryColumnMap = secondaryColumnMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
public static MergeOutput Merge(
|
public static MergeOutput Merge(
|
||||||
@@ -701,51 +726,26 @@ public static class TableMatcher
|
|||||||
bool[] secondaryUsed,
|
bool[] secondaryUsed,
|
||||||
bool ignoreCaseAndSpaces,
|
bool ignoreCaseAndSpaces,
|
||||||
bool partial = false,
|
bool partial = false,
|
||||||
bool removeIdenticalColumns = true)
|
bool mergeColumnsByHeader = true)
|
||||||
=> Merge(main, secondary, new MatchOptions
|
=> Merge(main, secondary, new MatchOptions
|
||||||
{
|
{
|
||||||
MainUsed = mainUsed,
|
MainUsed = mainUsed,
|
||||||
SecondaryUsed = secondaryUsed,
|
SecondaryUsed = secondaryUsed,
|
||||||
IgnoreCaseAndSpaces = ignoreCaseAndSpaces,
|
IgnoreCaseAndSpaces = ignoreCaseAndSpaces,
|
||||||
Kind = partial ? MatchKind.Partial : MatchKind.Exact
|
Kind = partial ? MatchKind.Partial : MatchKind.Exact
|
||||||
}, removeIdenticalColumns);
|
}, mergeColumnsByHeader);
|
||||||
|
|
||||||
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)
|
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();
|
var o = template?.Clone() ?? new MatchOptions();
|
||||||
o.MainUsed = used;
|
|
||||||
o.SecondaryUsed = used;
|
|
||||||
o.IgnoreCaseAndSpaces = ignore;
|
|
||||||
|
|
||||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
var rows = new List<string[]>();
|
var rows = new List<string[]>();
|
||||||
int removed = 0;
|
int removed = 0;
|
||||||
foreach (var row in table.Rows)
|
foreach (var row in table.Rows)
|
||||||
{
|
{
|
||||||
o.Token.ThrowIfCancellationRequested();
|
o.Token.ThrowIfCancellationRequested();
|
||||||
var parts = RowParts(row, idx, idx.Count, o);
|
var parts = row.Select(value => value ?? "").ToArray();
|
||||||
if (!IsEmptyParts(parts) && !seen.Add(JoinKey(parts)))
|
if (!seen.Add(JoinKey(parts)))
|
||||||
{
|
{
|
||||||
removed++;
|
removed++;
|
||||||
continue;
|
continue;
|
||||||
@@ -849,9 +849,10 @@ public static class ExcelExporter
|
|||||||
}
|
}
|
||||||
if (withMeta)
|
if (withMeta)
|
||||||
{
|
{
|
||||||
var type = result.Pairs.Count > i ? result.Pairs[i].Type : "";
|
var rowPairs = result.Pairs.Where(p => ReferenceEquals(p.RowA, row)).ToList();
|
||||||
var score = result.Pairs.Count > i ? result.Pairs[i].Score : 0;
|
var type = rowPairs.FirstOrDefault()?.Type ?? "";
|
||||||
var count = result.Pairs.Count > i ? result.Pairs[i].BCount : 0;
|
var score = rowPairs.Count == 0 ? 0 : rowPairs.Max(p => p.Score);
|
||||||
|
var count = rowPairs.Count;
|
||||||
SetCell(ws, i + 2, baseCols + 1, type);
|
SetCell(ws, i + 2, baseCols + 1, type);
|
||||||
SetCell(ws, i + 2, baseCols + 2, type == "нечёткое" ? score.ToString("0.#", CultureInfo.InvariantCulture) : "");
|
SetCell(ws, i + 2, baseCols + 2, type == "нечёткое" ? score.ToString("0.#", CultureInfo.InvariantCulture) : "");
|
||||||
SetCell(ws, i + 2, baseCols + 3, count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture));
|
SetCell(ws, i + 2, baseCols + 3, count == 0 ? "" : count.ToString(CultureInfo.InvariantCulture));
|
||||||
@@ -927,8 +928,9 @@ public static class ExcelExporter
|
|||||||
Row("Из них частичных", partial);
|
Row("Из них частичных", partial);
|
||||||
Row("Из них нечётких", fuzzy);
|
Row("Из них нечётких", fuzzy);
|
||||||
|
|
||||||
double avg = result.Pairs.Count == 0 ? 0 : Math.Round(result.Pairs.Average(p => p.BCount), 2);
|
var countsPerMainRow = result.Pairs.GroupBy(p => p.RowA).Select(group => group.First().BCount).ToList();
|
||||||
int maxBC = result.Pairs.Count == 0 ? 0 : result.Pairs.Max(p => p.BCount);
|
double avg = countsPerMainRow.Count == 0 ? 0 : Math.Round(countsPerMainRow.Average(), 2);
|
||||||
|
int maxBC = countsPerMainRow.Count == 0 ? 0 : countsPerMainRow.Max();
|
||||||
Row("Среднее B-совпадений (N→M)", avg);
|
Row("Среднее B-совпадений (N→M)", avg);
|
||||||
Row("Максимум B-совпадений (N→M)", maxBC);
|
Row("Максимум B-совпадений (N→M)", maxBC);
|
||||||
|
|
||||||
|
|||||||
+28
-54
@@ -137,8 +137,8 @@ public sealed class MainWindow : Window
|
|||||||
private readonly RadioButton _rbBoth = new() { Content = "Совпадения + чего нет в A" };
|
private readonly RadioButton _rbBoth = new() { Content = "Совпадения + чего нет в A" };
|
||||||
private readonly RadioButton _rbExact = new() { Content = "Точное совпадение", IsChecked = true };
|
private readonly RadioButton _rbExact = new() { Content = "Точное совпадение", IsChecked = true };
|
||||||
private readonly RadioButton _rbPartial = new() { Content = "Частичное (по фрагменту)" };
|
private readonly RadioButton _rbPartial = new() { Content = "Частичное (по фрагменту)" };
|
||||||
private readonly CheckBox _chkDedupe = new() { Content = "Удалять дубликаты по ключевым колонкам" };
|
private readonly CheckBox _chkDedupe = new() { Content = "Удалять полностью одинаковые строки" };
|
||||||
private readonly CheckBox _chkCleanCols = new() { Content = "Убирать одинаковые колонки после слияния", IsChecked = true };
|
private readonly CheckBox _chkCleanCols = new() { Content = "Объединять одноимённые колонки", IsChecked = true };
|
||||||
|
|
||||||
private readonly RadioButton _rbFuzzy = new() { Content = "Нечёткое (по сходству %)" };
|
private readonly RadioButton _rbFuzzy = new() { Content = "Нечёткое (по сходству %)" };
|
||||||
private readonly Slider _slFuzzy = new()
|
private readonly Slider _slFuzzy = new()
|
||||||
@@ -561,7 +561,7 @@ private Control BuildFilePanel(TextBox tb, Button browse, ComboBox cb, StackPane
|
|||||||
|
|
||||||
var colLabel = new TextBlock
|
var colLabel = new TextBlock
|
||||||
{
|
{
|
||||||
Text = "Ключевые колонки для сопоставления",
|
Text = "Ключи (выберите соответствующие поля в одинаковом порядке)",
|
||||||
Foreground = Ui.Muted,
|
Foreground = Ui.Muted,
|
||||||
FontWeight = FontWeight.SemiBold,
|
FontWeight = FontWeight.SemiBold,
|
||||||
FontSize = 12
|
FontSize = 12
|
||||||
@@ -900,6 +900,11 @@ private Control BuildFilePanel(TextBox tb, Button browse, ComboBox cb, StackPane
|
|||||||
ShowError("Отметьте хотя бы одну ключевую колонку в каждой таблице (по умолчанию отмечена первая).");
|
ShowError("Отметьте хотя бы одну ключевую колонку в каждой таблице (по умолчанию отмечена первая).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mainUsed.Count(x => x) != secUsed.Count(x => x))
|
||||||
|
{
|
||||||
|
ShowError("Выберите одинаковое число ключевых колонок в обеих таблицах.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
_cts = cts;
|
_cts = cts;
|
||||||
@@ -978,8 +983,9 @@ private Control BuildFilePanel(TextBox tb, Button browse, ComboBox cb, StackPane
|
|||||||
sb.Append($"Совпадений: {r.Matches.Count} · Только в A: {r.OnlyMain.Count} · Только в B: {r.OnlySecondary.Count}.");
|
sb.Append($"Совпадений: {r.Matches.Count} · Только в A: {r.OnlyMain.Count} · Только в B: {r.OnlySecondary.Count}.");
|
||||||
if (r.Pairs.Count > 0)
|
if (r.Pairs.Count > 0)
|
||||||
{
|
{
|
||||||
double avg = Math.Round(r.Pairs.Average(p => p.BCount), 2);
|
var countsPerMainRow = r.Pairs.GroupBy(p => p.RowA).Select(group => group.First().BCount).ToList();
|
||||||
int max = r.Pairs.Max(p => p.BCount);
|
double avg = Math.Round(countsPerMainRow.Average(), 2);
|
||||||
|
int max = countsPerMainRow.Max();
|
||||||
sb.Append($" Пары A↔B: {r.Pairs.Count}, ср. N→M: {avg.ToString(CultureInfo.InvariantCulture)}, макс: {max}.");
|
sb.Append($" Пары A↔B: {r.Pairs.Count}, ср. N→M: {avg.ToString(CultureInfo.InvariantCulture)}, макс: {max}.");
|
||||||
}
|
}
|
||||||
sb.Append($" Ключи заполнены: A={covA.ToString(CultureInfo.InvariantCulture)}%, B={covB.ToString(CultureInfo.InvariantCulture)}%.");
|
sb.Append($" Ключи заполнены: A={covA.ToString(CultureInfo.InvariantCulture)}%, B={covB.ToString(CultureInfo.InvariantCulture)}%.");
|
||||||
@@ -1081,59 +1087,27 @@ private Control BuildFilePanel(TextBox tb, Button browse, ComboBox cb, StackPane
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Build a union of both schemas. Columns with the same header are
|
headers = _merged?.Columns ?? new List<string>(main.Columns);
|
||||||
// aligned; B-only columns get their own position in the result.
|
data = _merged is not null ? new List<string[]>(_merged.Rows) : new List<string[]>();
|
||||||
headers = new List<string>(main.Columns);
|
|
||||||
var mainColumnMap = Enumerable.Range(0, main.Columns.Count).ToArray();
|
|
||||||
var secondaryColumnMap = new int[sec.Columns.Count];
|
|
||||||
var matchedSecondaryColumns = new HashSet<int>();
|
|
||||||
|
|
||||||
for (int b = 0; b < sec.Columns.Count; b++)
|
|
||||||
{
|
|
||||||
int matchingMain = -1;
|
|
||||||
for (int a = 0; a < main.Columns.Count; a++)
|
|
||||||
{
|
|
||||||
if (!matchedSecondaryColumns.Contains(a) &&
|
|
||||||
string.Equals(main.Columns[a].Trim(), sec.Columns[b].Trim(), StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
matchingMain = a;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchingMain >= 0)
|
|
||||||
{
|
|
||||||
secondaryColumnMap[b] = matchingMain;
|
|
||||||
matchedSecondaryColumns.Add(matchingMain);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var header = sec.Columns[b];
|
|
||||||
if (headers.Contains(header, StringComparer.OrdinalIgnoreCase))
|
|
||||||
header = $"B: {header}";
|
|
||||||
secondaryColumnMap[b] = headers.Count;
|
|
||||||
headers.Add(header);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] CombineRows(string[] rowA, string[] rowB)
|
|
||||||
{
|
|
||||||
var combined = new string[headers.Count];
|
|
||||||
Array.Fill(combined, "");
|
|
||||||
for (int a = 0; a < mainColumnMap.Length && a < rowA.Length; a++)
|
|
||||||
combined[mainColumnMap[a]] = rowA[a];
|
|
||||||
for (int b = 0; b < secondaryColumnMap.Length && b < rowB.Length; b++)
|
|
||||||
combined[secondaryColumnMap[b]] = rowB[b];
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pair in result.Pairs)
|
|
||||||
data.Add(CombineRows(pair.RowA, pair.RowB));
|
|
||||||
sources = new List<string>();
|
sources = new List<string>();
|
||||||
foreach (var _ in result.Pairs) sources.Add("A+B");
|
foreach (var _ in result.Pairs) sources.Add("A+B");
|
||||||
foreach (var r in result.OnlySecondary)
|
foreach (var r in result.OnlySecondary)
|
||||||
{
|
{
|
||||||
data.Add(CombineRows(Array.Empty<string>(), r));
|
var row = new string[headers.Count];
|
||||||
|
Array.Fill(row, "");
|
||||||
|
if (_merged is not null)
|
||||||
|
{
|
||||||
|
for (int b = 0; b < _merged.SecondaryColumnMap.Length && b < r.Length; b++)
|
||||||
|
{
|
||||||
|
int destination = _merged.SecondaryColumnMap[b];
|
||||||
|
if (destination >= 0 && destination < row.Length) row[destination] = r[b];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (int b = 0; b < r.Length && b < row.Length; b++) row[b] = r[b];
|
||||||
|
}
|
||||||
|
data.Add(row);
|
||||||
sources.Add("B");
|
sources.Add("B");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in new issue
Block a user