< Summary

Information
Class: Services.ApiServices._1CApi.Nomenclature.ProducerDirectory
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Nomenclature/ProducerDirectory.cs
Line coverage
97%
Covered lines: 97
Uncovered lines: 3
Coverable lines: 100
Total lines: 233
Line coverage: 97%
Branch coverage
92%
Covered branches: 83
Total branches: 90
Branch coverage: 92.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor()100%11100%
get_GroupCount()100%210%
Build(...)93.33%3030100%
GroupsOfName(...)100%22100%
GroupsOfPrefix(...)83.33%66100%
Resolve(...)100%1414100%
NameMentionedIn(...)88.88%181894.11%
NamesOfGroup(...)50%22100%
Norm(...)100%11100%
AddName(...)100%22100%
RawGroupsOfNorm(...)100%44100%
CreateNameGroup(...)100%11100%
Representatives(...)100%11100%
EnsureGroup(...)100%11100%
Find(...)100%44100%
Union(...)83.33%6688.88%
GetOrAdd(...)100%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Nomenclature/ProducerDirectory.cs

#LineLine coverage
 1using OzonDomains.Nomenclature;
 2
 3namespace Services.ApiServices._1CApi.Nomenclature;
 4
 5/// <summary>Строка справочника производителей (<c>et_producers</c>): <see cref="RealId"/> объединяет разные написания о
 6public sealed record ProducerRow(int Id, int RealId, string Name, string? Prefix, string? ExistName, string? MarketPrefi
 7
 8/// <summary>Ручная связь написаний (<c>OneCProducerAliases</c>): обе стороны считаются одним брендом.</summary>
 9public sealed record ProducerLink(string AliasName, string CanonicalName);
 10
 11public enum ProducerResolutionKind
 12{
 13    /// <summary>Бренд определён (одна или несколько групп-кандидатов с равным правом).</summary>
 14    Known,
 15
 16    /// <summary>Бренд определить нечем (нет префикса и имени, либо имя — заглушка).</summary>
 17    Unknown,
 18
 19    /// <summary>Префикс общий у нескольких брендов (HND, MTL…) и выбрать между ними нечем.</summary>
 20    Ambiguous
 21}
 22
 23/// <param name="Groups">Ключи групп бренда; для <see cref="ProducerResolutionKind.Unknown"/> пусто.</param>
 24/// <param name="Source">Чем определено: <c>prefix</c>, <c>prefix+name</c>, <c>name</c>, <c>placeholder</c>, <c>none</c>
 25public sealed record ProducerResolution(ProducerResolutionKind Kind, IReadOnlySet<string> Groups, string Source)
 26{
 27    public static readonly IReadOnlySet<string> NoGroups = new HashSet<string>();
 28}
 29
 30/// <summary>
 31/// Группы эквивалентных написаний бренда. Основа — <c>et_producers.realid</c> (19 тыс. имён → 16 тыс. групп),
 32/// сверху ручные связи (<c>OneCProducerAliases</c>); связь двусторонняя: <c>KAYABA ↔ KYB</c>, а не «KAYABA → KYB».
 33/// Чистый класс без ввода-вывода: сборка в <see cref="Build"/>, чтение БД — в <c>ProducerRegistry</c>.
 34/// </summary>
 35public sealed class ProducerDirectory
 36{
 37    /// <summary>
 38    /// Имя производителя, которым заказ помечается, когда бренд не известен (ключ без префикса бренда).
 39    /// Это заглушка, а не бренд: искать по ней нельзя, иначе карточки Nissan, Mazda, Samson… «другого бренда».
 40    /// </summary>
 141    private static readonly HashSet<string> PlaceholderNames = new(StringComparer.Ordinal) { "DAIHATSU" };
 42
 2143    private readonly Dictionary<string, string> _parent = new(StringComparer.Ordinal);
 2144    private readonly Dictionary<string, HashSet<string>> _nameToRawGroups = new(StringComparer.Ordinal);
 2145    private readonly Dictionary<string, HashSet<string>> _prefixToRawGroups = new(StringComparer.Ordinal);
 2146    private readonly Dictionary<string, HashSet<string>> _namesByGroup = new(StringComparer.Ordinal);
 47
 2148    private ProducerDirectory()
 49    {
 2150    }
 51
 052    public int GroupCount => _namesByGroup.Count;
 53
 54    public static ProducerDirectory Build(IEnumerable<ProducerRow> rows, IEnumerable<ProducerLink>? links = null)
 55    {
 2156        var dir = new ProducerDirectory();
 57
 64858        foreach (var row in rows)
 59        {
 30360            var group = "g" + (row.RealId != 0 ? row.RealId : row.Id);
 30361            dir.EnsureGroup(group);
 62
 242463            foreach (var name in new[] { row.Name, row.Prefix, row.ExistName })
 90964                dir.AddName(name, group);
 65
 30366            var prefix = row.MarketPrefix?.Trim().ToUpperInvariant();
 30367            if (!string.IsNullOrEmpty(prefix) && prefix != "0")
 21368                GetOrAdd(dir._prefixToRawGroups, prefix).Add(group);
 69        }
 70
 4671        foreach (var link in links ?? [])
 72        {
 273            var alias = Norm(link.AliasName);
 274            var canonical = Norm(link.CanonicalName);
 275            if (alias.Length == 0 || canonical.Length == 0 || alias == canonical)
 76                continue;
 77
 278            var aliasGroups = dir.RawGroupsOfNorm(alias) ?? dir.CreateNameGroup(alias, link.AliasName);
 279            var canonicalGroups = dir.RawGroupsOfNorm(canonical) ?? dir.CreateNameGroup(canonical, link.CanonicalName);
 880            foreach (var a in aliasGroups.ToList())
 881                foreach (var c in canonicalGroups.ToList())
 282                    dir.Union(a, c);
 83        }
 84
 2185        return dir;
 86    }
 87
 88    /// <summary>Группы бренда по имени (с учётом диакритики, регистра и ручных связей); пусто, если имя не известно.</s
 89    public IReadOnlySet<string> GroupsOfName(string? name)
 90    {
 14191        var raw = RawGroupsOfNorm(Norm(name));
 14192        return raw == null ? ProducerResolution.NoGroups : Representatives(raw);
 93    }
 94
 95    /// <summary>Группы бренда по трёхбуквенному коду ключа (<c>FEB</c>, <c>KYB</c>, <c>MET</c>).</summary>
 96    public IReadOnlySet<string> GroupsOfPrefix(string? prefix)
 97    {
 5798        var key = prefix?.Trim().ToUpperInvariant();
 5799        return !string.IsNullOrEmpty(key) && _prefixToRawGroups.TryGetValue(key, out var raw)
 57100            ? Representatives(raw)
 57101            : ProducerResolution.NoGroups;
 102    }
 103
 104    /// <summary>
 105    /// Бренд товара из заказа. Однозначный префикс ключа сильнее имени <c>EtProducer</c> (<c>=MET</c> — это Metaco,
 106    /// хотя в заказе производитель записан как «MET»). Имя-заглушка без префикса бренда не считается.
 107    /// </summary>
 108    public ProducerResolution Resolve(ParsedProductRef parsed, string? etProducerName)
 109    {
 57110        var byPrefix = GroupsOfPrefix(parsed.BrandPrefix);
 57111        if (byPrefix.Count == 1)
 39112            return new ProducerResolution(ProducerResolutionKind.Known, byPrefix, "prefix");
 113
 18114        if (byPrefix.Count > 1)
 115        {
 3116            var narrowed = byPrefix.Intersect(GroupsOfName(etProducerName)).ToHashSet(StringComparer.Ordinal);
 3117            return narrowed.Count > 0
 3118                ? new ProducerResolution(ProducerResolutionKind.Known, narrowed, "prefix+name")
 3119                : new ProducerResolution(ProducerResolutionKind.Ambiguous, byPrefix, "prefix");
 120        }
 121
 15122        var name = Norm(etProducerName);
 15123        if (name.Length == 0)
 6124            return new ProducerResolution(ProducerResolutionKind.Unknown, ProducerResolution.NoGroups, "none");
 125
 9126        if (parsed.BrandPrefix.Length == 0 && PlaceholderNames.Contains(name))
 2127            return new ProducerResolution(ProducerResolutionKind.Unknown, ProducerResolution.NoGroups, "placeholder");
 128
 7129        var byName = GroupsOfName(etProducerName);
 7130        return byName.Count > 0
 7131            ? new ProducerResolution(ProducerResolutionKind.Known, byName, "name")
 7132            : new ProducerResolution(ProducerResolutionKind.Unknown, ProducerResolution.NoGroups, "none");
 133    }
 134
 135    /// <summary>
 136    /// Упомянут ли бренд группы в названии товара («NARVA 17048 Лампа», «FEBI 06161 …»). Сравниваются целые слова
 137    /// и пары соседних слов («FEBI BILSTEIN»); имена короче трёх знаков не участвуют — слишком много ложных совпадений.
 138    /// </summary>
 139    public bool NameMentionedIn(string? title, IReadOnlySet<string> groups)
 140    {
 12141        if (string.IsNullOrWhiteSpace(title) || groups.Count == 0)
 2142            return false;
 143
 10144        var tokens = title.Split([' ', ',', '.', ';', ':', '(', ')', '/', '"', '«', '»', '-', '_'], StringSplitOptions.R
 10145            .Select(Norm)
 29146            .Where(t => t.Length > 0)
 10147            .ToList();
 10148        if (tokens.Count == 0)
 0149            return false;
 150
 10151        var phrases = new HashSet<string>(tokens, StringComparer.Ordinal);
 58152        for (var i = 0; i + 1 < tokens.Count; i++)
 19153            phrases.Add(tokens[i] + tokens[i + 1]);
 154
 36155        foreach (var group in groups)
 156        {
 10157            if (!_namesByGroup.TryGetValue(group, out var names))
 158                continue;
 159
 24160            if (names.Any(n => n.Length >= 3 && phrases.Contains(n)))
 4161                return true;
 162        }
 163
 6164        return false;
 4165    }
 166
 167    /// <summary>Написания (нормализованные), объединённые в группу; для показа в диагностике.</summary>
 168    public IReadOnlyCollection<string> NamesOfGroup(string group)
 9169        => _namesByGroup.TryGetValue(group, out var names) ? names.OrderBy(n => n, StringComparer.Ordinal).ToList() : []
 170
 1099171    private static string Norm(string? value) => ArticleKey.Key(value);
 172
 173    private void AddName(string? name, string group)
 174    {
 910175        var norm = Norm(name);
 910176        if (norm.Length == 0)
 303177            return;
 178
 607179        GetOrAdd(_nameToRawGroups, norm).Add(group);
 607180        GetOrAdd(_namesByGroup, group).Add(norm);
 607181    }
 182
 183    private HashSet<string>? RawGroupsOfNorm(string norm)
 145184        => norm.Length > 0 && _nameToRawGroups.TryGetValue(norm, out var raw) ? raw : null;
 185
 186    private HashSet<string> CreateNameGroup(string norm, string? name)
 187    {
 1188        var group = "n:" + norm;
 1189        EnsureGroup(group);
 1190        AddName(name, group);
 1191        return _nameToRawGroups[norm];
 192    }
 193
 194    private IReadOnlySet<string> Representatives(IEnumerable<string> rawGroups)
 163195        => rawGroups.Select(Find).ToHashSet(StringComparer.Ordinal);
 196
 197    private void EnsureGroup(string group)
 198    {
 304199        _parent.TryAdd(group, group);
 304200        GetOrAdd(_namesByGroup, group);
 304201    }
 202
 203    private string Find(string group)
 204    {
 170205        var root = group;
 173206        while (_parent.TryGetValue(root, out var parent) && parent != root)
 3207            root = parent;
 208
 170209        return root;
 210    }
 211
 212    private void Union(string a, string b)
 213    {
 2214        var rootA = Find(a);
 2215        var rootB = Find(b);
 2216        if (rootA == rootB)
 0217            return;
 218
 219        // Имена объединённой группы нужны для NameMentionedIn под представителем.
 2220        var winner = string.CompareOrdinal(rootA, rootB) <= 0 ? rootA : rootB;
 2221        var loser = winner == rootA ? rootB : rootA;
 2222        _parent[loser] = winner;
 2223        GetOrAdd(_namesByGroup, winner).UnionWith(GetOrAdd(_namesByGroup, loser));
 2224    }
 225
 226    private static HashSet<string> GetOrAdd(Dictionary<string, HashSet<string>> map, string key)
 227    {
 1735228        if (!map.TryGetValue(key, out var set))
 715229            map[key] = set = new HashSet<string>(StringComparer.Ordinal);
 230
 1735231        return set;
 232    }
 233}