< Summary

Information
Class: Services.ApiServices._1CApi.Nomenclature.ProducerResolution
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Nomenclature/ProducerDirectory.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 233
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Kind()100%11100%
.cctor()100%11100%

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>
 27825public sealed record ProducerResolution(ProducerResolutionKind Kind, IReadOnlySet<string> Groups, string Source)
 26{
 127    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>
 41    private static readonly HashSet<string> PlaceholderNames = new(StringComparer.Ordinal) { "DAIHATSU" };
 42
 43    private readonly Dictionary<string, string> _parent = new(StringComparer.Ordinal);
 44    private readonly Dictionary<string, HashSet<string>> _nameToRawGroups = new(StringComparer.Ordinal);
 45    private readonly Dictionary<string, HashSet<string>> _prefixToRawGroups = new(StringComparer.Ordinal);
 46    private readonly Dictionary<string, HashSet<string>> _namesByGroup = new(StringComparer.Ordinal);
 47
 48    private ProducerDirectory()
 49    {
 50    }
 51
 52    public int GroupCount => _namesByGroup.Count;
 53
 54    public static ProducerDirectory Build(IEnumerable<ProducerRow> rows, IEnumerable<ProducerLink>? links = null)
 55    {
 56        var dir = new ProducerDirectory();
 57
 58        foreach (var row in rows)
 59        {
 60            var group = "g" + (row.RealId != 0 ? row.RealId : row.Id);
 61            dir.EnsureGroup(group);
 62
 63            foreach (var name in new[] { row.Name, row.Prefix, row.ExistName })
 64                dir.AddName(name, group);
 65
 66            var prefix = row.MarketPrefix?.Trim().ToUpperInvariant();
 67            if (!string.IsNullOrEmpty(prefix) && prefix != "0")
 68                GetOrAdd(dir._prefixToRawGroups, prefix).Add(group);
 69        }
 70
 71        foreach (var link in links ?? [])
 72        {
 73            var alias = Norm(link.AliasName);
 74            var canonical = Norm(link.CanonicalName);
 75            if (alias.Length == 0 || canonical.Length == 0 || alias == canonical)
 76                continue;
 77
 78            var aliasGroups = dir.RawGroupsOfNorm(alias) ?? dir.CreateNameGroup(alias, link.AliasName);
 79            var canonicalGroups = dir.RawGroupsOfNorm(canonical) ?? dir.CreateNameGroup(canonical, link.CanonicalName);
 80            foreach (var a in aliasGroups.ToList())
 81                foreach (var c in canonicalGroups.ToList())
 82                    dir.Union(a, c);
 83        }
 84
 85        return dir;
 86    }
 87
 88    /// <summary>Группы бренда по имени (с учётом диакритики, регистра и ручных связей); пусто, если имя не известно.</s
 89    public IReadOnlySet<string> GroupsOfName(string? name)
 90    {
 91        var raw = RawGroupsOfNorm(Norm(name));
 92        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    {
 98        var key = prefix?.Trim().ToUpperInvariant();
 99        return !string.IsNullOrEmpty(key) && _prefixToRawGroups.TryGetValue(key, out var raw)
 100            ? Representatives(raw)
 101            : 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    {
 110        var byPrefix = GroupsOfPrefix(parsed.BrandPrefix);
 111        if (byPrefix.Count == 1)
 112            return new ProducerResolution(ProducerResolutionKind.Known, byPrefix, "prefix");
 113
 114        if (byPrefix.Count > 1)
 115        {
 116            var narrowed = byPrefix.Intersect(GroupsOfName(etProducerName)).ToHashSet(StringComparer.Ordinal);
 117            return narrowed.Count > 0
 118                ? new ProducerResolution(ProducerResolutionKind.Known, narrowed, "prefix+name")
 119                : new ProducerResolution(ProducerResolutionKind.Ambiguous, byPrefix, "prefix");
 120        }
 121
 122        var name = Norm(etProducerName);
 123        if (name.Length == 0)
 124            return new ProducerResolution(ProducerResolutionKind.Unknown, ProducerResolution.NoGroups, "none");
 125
 126        if (parsed.BrandPrefix.Length == 0 && PlaceholderNames.Contains(name))
 127            return new ProducerResolution(ProducerResolutionKind.Unknown, ProducerResolution.NoGroups, "placeholder");
 128
 129        var byName = GroupsOfName(etProducerName);
 130        return byName.Count > 0
 131            ? new ProducerResolution(ProducerResolutionKind.Known, byName, "name")
 132            : 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    {
 141        if (string.IsNullOrWhiteSpace(title) || groups.Count == 0)
 142            return false;
 143
 144        var tokens = title.Split([' ', ',', '.', ';', ':', '(', ')', '/', '"', '«', '»', '-', '_'], StringSplitOptions.R
 145            .Select(Norm)
 146            .Where(t => t.Length > 0)
 147            .ToList();
 148        if (tokens.Count == 0)
 149            return false;
 150
 151        var phrases = new HashSet<string>(tokens, StringComparer.Ordinal);
 152        for (var i = 0; i + 1 < tokens.Count; i++)
 153            phrases.Add(tokens[i] + tokens[i + 1]);
 154
 155        foreach (var group in groups)
 156        {
 157            if (!_namesByGroup.TryGetValue(group, out var names))
 158                continue;
 159
 160            if (names.Any(n => n.Length >= 3 && phrases.Contains(n)))
 161                return true;
 162        }
 163
 164        return false;
 165    }
 166
 167    /// <summary>Написания (нормализованные), объединённые в группу; для показа в диагностике.</summary>
 168    public IReadOnlyCollection<string> NamesOfGroup(string group)
 169        => _namesByGroup.TryGetValue(group, out var names) ? names.OrderBy(n => n, StringComparer.Ordinal).ToList() : []
 170
 171    private static string Norm(string? value) => ArticleKey.Key(value);
 172
 173    private void AddName(string? name, string group)
 174    {
 175        var norm = Norm(name);
 176        if (norm.Length == 0)
 177            return;
 178
 179        GetOrAdd(_nameToRawGroups, norm).Add(group);
 180        GetOrAdd(_namesByGroup, group).Add(norm);
 181    }
 182
 183    private HashSet<string>? RawGroupsOfNorm(string norm)
 184        => norm.Length > 0 && _nameToRawGroups.TryGetValue(norm, out var raw) ? raw : null;
 185
 186    private HashSet<string> CreateNameGroup(string norm, string? name)
 187    {
 188        var group = "n:" + norm;
 189        EnsureGroup(group);
 190        AddName(name, group);
 191        return _nameToRawGroups[norm];
 192    }
 193
 194    private IReadOnlySet<string> Representatives(IEnumerable<string> rawGroups)
 195        => rawGroups.Select(Find).ToHashSet(StringComparer.Ordinal);
 196
 197    private void EnsureGroup(string group)
 198    {
 199        _parent.TryAdd(group, group);
 200        GetOrAdd(_namesByGroup, group);
 201    }
 202
 203    private string Find(string group)
 204    {
 205        var root = group;
 206        while (_parent.TryGetValue(root, out var parent) && parent != root)
 207            root = parent;
 208
 209        return root;
 210    }
 211
 212    private void Union(string a, string b)
 213    {
 214        var rootA = Find(a);
 215        var rootB = Find(b);
 216        if (rootA == rootB)
 217            return;
 218
 219        // Имена объединённой группы нужны для NameMentionedIn под представителем.
 220        var winner = string.CompareOrdinal(rootA, rootB) <= 0 ? rootA : rootB;
 221        var loser = winner == rootA ? rootB : rootA;
 222        _parent[loser] = winner;
 223        GetOrAdd(_namesByGroup, winner).UnionWith(GetOrAdd(_namesByGroup, loser));
 224    }
 225
 226    private static HashSet<string> GetOrAdd(Dictionary<string, HashSet<string>> map, string key)
 227    {
 228        if (!map.TryGetValue(key, out var set))
 229            map[key] = set = new HashSet<string>(StringComparer.Ordinal);
 230
 231        return set;
 232    }
 233}

Methods/Properties

get_Kind()
.cctor()