< Summary

Information
Class: Services.ApiServices._1CApi.OData.OneCNomenclatureMapStore
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapStore.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 133
Coverable lines: 133
Total lines: 283
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 46
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
LoadAllAsync()100%210%
FindAsync()0%2040%
FindByArticleAsync()0%620%
FindByRefKeysAsync()0%4260%
UpsertAsync()100%210%
BulkUpsertAsync()0%156120%
GetAllKeysAsync()0%620%
FindProducerAliasAsync()0%2040%
UpsertProducerAliasAsync()0%4260%
BuildPlaceholders(...)0%4260%
ToEntry(...)100%210%
Truncate(...)0%2040%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapStore.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.DependencyInjection;
 3using OzonDomains.Models;
 4using OzonRepositories.Context;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8/// <summary>Найденная карточка 1С по нормализованному ключу.</summary>
 9public sealed record OneCMapEntry(Guid RefKey, string ArticleNorm, string ProducerNorm, string? ProducerName);
 10
 11/// <summary>Каноническое значение производителя, к которому сведён альтернативный вариант написания.</summary>
 12public sealed record OneCProducerAliasEntry(Guid RefKey, string? CanonicalName);
 13
 14/// <summary>
 15/// Хранилище соответствий «нормализованный артикул + производитель» → карточка 1С.
 16/// Живёт в общей БД, поэтому карта одна на оба прод-инстанса.
 17/// </summary>
 18public interface IOneCNomenclatureMapStore
 19{
 20    Task<OneCMapEntry?> FindAsync(string articleNorm, string producerNorm, CancellationToken cancellationToken = default
 21
 22    Task<IReadOnlyList<OneCMapEntry>> FindByArticleAsync(string articleNorm, CancellationToken cancellationToken = defau
 23
 24    /// <summary>
 25    /// Обратный поиск: карточка 1С → её артикул и производитель. Нужен там, где отправной точкой
 26    /// служит GUID из регистра 1С, а показать надо человекочитаемые реквизиты.
 27    /// Если на один RefKey заведено несколько строк, берётся первая — реквизиты у них совпадают.
 28    /// </summary>
 29    Task<IReadOnlyDictionary<Guid, OneCMapEntry>> FindByRefKeysAsync(
 30        IReadOnlyCollection<Guid> refKeys,
 31        CancellationToken cancellationToken = default);
 32
 33    Task UpsertAsync(OneCMapEntry entry, string? rawArticle, string source, CancellationToken cancellationToken = defaul
 34
 35    Task<int> BulkUpsertAsync(IReadOnlyList<(OneCMapEntry Entry, string? RawArticle)> items, string source, Cancellation
 36
 37    /// <summary>Все известные соответствия — для фоновой сверки с 1С.</summary>
 38    Task<IReadOnlyDictionary<(string ArticleNorm, string ProducerNorm), Guid>> GetAllKeysAsync(CancellationToken cancell
 39
 40    Task<OneCProducerAliasEntry?> FindProducerAliasAsync(string aliasNorm, CancellationToken cancellationToken = default
 41
 42    Task UpsertProducerAliasAsync(string aliasNorm, Guid canonicalRefKey, string? canonicalName, string source, Cancella
 43}
 44
 45public sealed class OneCNomenclatureMapStore : IOneCNomenclatureMapStore, Services.ApiServices._1CApi.Nomenclature.INome
 46{
 47    private readonly IServiceScopeFactory _scopeFactory;
 48
 049    public OneCNomenclatureMapStore(IServiceScopeFactory scopeFactory)
 50    {
 051        _scopeFactory = scopeFactory;
 052    }
 53
 54    public async Task<IReadOnlyList<Services.ApiServices._1CApi.Nomenclature.MapRow>> LoadAllAsync(CancellationToken can
 55    {
 056        await using var scope = _scopeFactory.CreateAsyncScope();
 057        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 58
 059        return await db.OneCNomenclatureMaps
 060            .AsNoTracking()
 061            .Select(m => new Services.ApiServices._1CApi.Nomenclature.MapRow(
 062                m.ArticleNorm, m.ProducerNorm, m.OneCProducerName, m.OneCRefKey, m.OneCArticleRaw, m.Source))
 063            .ToListAsync(cancellationToken);
 064    }
 65
 66    public async Task<OneCMapEntry?> FindAsync(string articleNorm, string producerNorm, CancellationToken cancellationTo
 67    {
 068        if (string.IsNullOrEmpty(articleNorm))
 069            return null;
 70
 071        await using var scope = _scopeFactory.CreateAsyncScope();
 072        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 73
 074        var row = await db.OneCNomenclatureMaps
 075            .AsNoTracking()
 076            .FirstOrDefaultAsync(m => m.ArticleNorm == articleNorm && m.ProducerNorm == producerNorm, cancellationToken)
 77
 078        return row == null ? null : ToEntry(row);
 079    }
 80
 81    public async Task<IReadOnlyList<OneCMapEntry>> FindByArticleAsync(string articleNorm, CancellationToken cancellation
 82    {
 083        if (string.IsNullOrEmpty(articleNorm))
 084            return [];
 85
 086        await using var scope = _scopeFactory.CreateAsyncScope();
 087        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 88
 089        var rows = await db.OneCNomenclatureMaps
 090            .AsNoTracking()
 091            .Where(m => m.ArticleNorm == articleNorm)
 092            .ToListAsync(cancellationToken);
 93
 094        return rows.Select(ToEntry).ToList();
 095    }
 96
 97    public async Task<IReadOnlyDictionary<Guid, OneCMapEntry>> FindByRefKeysAsync(
 98        IReadOnlyCollection<Guid> refKeys,
 99        CancellationToken cancellationToken = default)
 100    {
 0101        if (refKeys.Count == 0)
 0102            return new Dictionary<Guid, OneCMapEntry>();
 103
 0104        await using var scope = _scopeFactory.CreateAsyncScope();
 0105        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 106
 0107        var distinct = refKeys.Distinct().ToList();
 0108        var result = new Dictionary<Guid, OneCMapEntry>(distinct.Count);
 109
 110        // Порциями, чтобы не упереться в лимит параметров запроса при большой странице.
 111        const int chunkSize = 200;
 0112        for (var offset = 0; offset < distinct.Count; offset += chunkSize)
 113        {
 0114            var chunk = distinct.Skip(offset).Take(chunkSize).ToList();
 0115            var rows = await db.OneCNomenclatureMaps
 0116                .AsNoTracking()
 0117                .Where(m => chunk.Contains(m.OneCRefKey))
 0118                .ToListAsync(cancellationToken);
 119
 0120            foreach (var row in rows)
 0121                result.TryAdd(row.OneCRefKey, ToEntry(row));
 122        }
 123
 0124        return result;
 0125    }
 126
 127    public async Task UpsertAsync(OneCMapEntry entry, string? rawArticle, string source, CancellationToken cancellationT
 0128        => await BulkUpsertAsync([(entry, rawArticle)], source, cancellationToken);
 129
 130    /// <summary>
 131    /// Пишет пачку соответствий одним INSERT ... ON DUPLICATE KEY UPDATE — атомарно относительно
 132    /// второго прод-инстанса, который может писать тот же ключ параллельно.
 133    /// </summary>
 134    public async Task<int> BulkUpsertAsync(
 135        IReadOnlyList<(OneCMapEntry Entry, string? RawArticle)> items,
 136        string source,
 137        CancellationToken cancellationToken = default)
 138    {
 0139        if (items.Count == 0)
 0140            return 0;
 141
 0142        await using var scope = _scopeFactory.CreateAsyncScope();
 0143        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 144
 0145        var affected = 0;
 146        const int chunkSize = 500;
 147
 0148        for (var offset = 0; offset < items.Count; offset += chunkSize)
 149        {
 0150            var chunk = items.Skip(offset).Take(chunkSize).ToList();
 0151            var parameters = new List<object>();
 0152            var rows = new List<string>(chunk.Count);
 153
 0154            foreach (var (entry, rawArticle) in chunk)
 155            {
 0156                var values = new[]
 0157                {
 0158                    Truncate(entry.ArticleNorm, 128) ?? string.Empty,
 0159                    Truncate(entry.ProducerNorm, 128) ?? string.Empty,
 0160                    entry.RefKey.ToString(),
 0161                    Truncate(rawArticle, 191),
 0162                    Truncate(entry.ProducerName, 191),
 0163                    Truncate(source, 32) ?? string.Empty,
 0164                };
 165
 0166                var (placeholders, _) = BuildPlaceholders(values, parameters);
 0167                rows.Add($"({string.Join(", ", placeholders)}, UTC_TIMESTAMP(), UTC_TIMESTAMP())");
 168            }
 169
 0170            var sql = $"""
 0171                INSERT INTO `OneCNomenclatureMaps`
 0172                    (`ArticleNorm`, `ProducerNorm`, `OneCRefKey`, `OneCArticleRaw`, `OneCProducerName`, `Source`, `Creat
 0173                VALUES {string.Join(", ", rows)}
 0174                ON DUPLICATE KEY UPDATE
 0175                    `OneCRefKey` = VALUES(`OneCRefKey`),
 0176                    `OneCArticleRaw` = VALUES(`OneCArticleRaw`),
 0177                    `OneCProducerName` = VALUES(`OneCProducerName`),
 0178                    `Source` = VALUES(`Source`),
 0179                    `UpdatedAt` = UTC_TIMESTAMP()
 0180                """;
 181
 0182            affected += await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 183        }
 184
 0185        return affected;
 0186    }
 187
 188    public async Task<IReadOnlyDictionary<(string ArticleNorm, string ProducerNorm), Guid>> GetAllKeysAsync(
 189        CancellationToken cancellationToken = default)
 190    {
 0191        await using var scope = _scopeFactory.CreateAsyncScope();
 0192        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 193
 0194        var rows = await db.OneCNomenclatureMaps
 0195            .AsNoTracking()
 0196            .Select(m => new { m.ArticleNorm, m.ProducerNorm, m.OneCRefKey })
 0197            .ToListAsync(cancellationToken);
 198
 0199        var result = new Dictionary<(string, string), Guid>(rows.Count);
 0200        foreach (var row in rows)
 0201            result[(row.ArticleNorm, row.ProducerNorm)] = row.OneCRefKey;
 0202        return result;
 0203    }
 204
 205    public async Task<OneCProducerAliasEntry?> FindProducerAliasAsync(string aliasNorm, CancellationToken cancellationTo
 206    {
 0207        if (string.IsNullOrEmpty(aliasNorm))
 0208            return null;
 209
 0210        await using var scope = _scopeFactory.CreateAsyncScope();
 0211        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 212
 0213        var row = await db.OneCProducerAliases
 0214            .AsNoTracking()
 0215            .FirstOrDefaultAsync(a => a.AliasNorm == aliasNorm, cancellationToken);
 216
 0217        return row == null ? null : new OneCProducerAliasEntry(row.CanonicalRefKey, row.CanonicalName);
 0218    }
 219
 220    public async Task UpsertProducerAliasAsync(
 221        string aliasNorm,
 222        Guid canonicalRefKey,
 223        string? canonicalName,
 224        string source,
 225        CancellationToken cancellationToken = default)
 226    {
 0227        if (string.IsNullOrEmpty(aliasNorm))
 0228            return;
 229
 0230        await using var scope = _scopeFactory.CreateAsyncScope();
 0231        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 232
 233        // Как и в BulkUpsertAsync: NULL — литералом, DBNull через ExecuteSqlRawAsync не проходит.
 0234        var values = new[]
 0235        {
 0236            Truncate(aliasNorm, 128) ?? string.Empty,
 0237            canonicalRefKey.ToString(),
 0238            Truncate(canonicalName, 191),
 0239            Truncate(source, 32) ?? string.Empty,
 0240        };
 0241        var (placeholders, parameters) = BuildPlaceholders(values);
 242
 0243        var sql = $"INSERT INTO `OneCProducerAliases` "
 0244                  + "(`AliasNorm`, `CanonicalRefKey`, `CanonicalName`, `Source`, `CreatedAt`) "
 0245                  + $"VALUES ({string.Join(", ", placeholders)}, UTC_TIMESTAMP()) "
 0246                  + "ON DUPLICATE KEY UPDATE "
 0247                  + "`CanonicalRefKey` = VALUES(`CanonicalRefKey`), "
 0248                  + "`CanonicalName` = VALUES(`CanonicalName`)";
 249
 0250        await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 0251    }
 252
 253    /// <summary>
 254    /// Раскладывает значения на позиционные плейсхолдеры EF Core, подставляя литерал NULL вместо
 255    /// параметра для пустых значений: ExecuteSqlRawAsync не принимает DBNull («provider doesn't have
 256    /// a store type mapping for properties of type DBNull»), а вывести тип из null-объекта EF Core
 257    /// не может. Карточка без производителя — обычное дело, поэтому путь с NULL основной.
 258    /// </summary>
 259    private static (List<string> Placeholders, List<object> Parameters) BuildPlaceholders(
 260        IReadOnlyList<string?> values,
 261        List<object>? parameters = null)
 262    {
 0263        parameters ??= [];
 0264        var placeholders = new List<string>(values.Count);
 0265        foreach (var value in values)
 266        {
 0267            if (value == null)
 268            {
 0269                placeholders.Add("NULL");
 0270                continue;
 271            }
 0272            placeholders.Add($"{{{parameters.Count}}}");
 0273            parameters.Add(value);
 274        }
 0275        return (placeholders, parameters);
 276    }
 277
 278    private static OneCMapEntry ToEntry(OneCNomenclatureMap row)
 0279        => new(row.OneCRefKey, row.ArticleNorm, row.ProducerNorm, row.OneCProducerName);
 280
 281    private static string? Truncate(string? value, int maxLength)
 0282        => value != null && value.Length > maxLength ? value[..maxLength] : value;
 283}