< Summary

Information
Class: Services.ApiServices._1CApi.OData.OneCProducerAliasEntry
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapStore.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 283
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_RefKey()100%11100%

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>
 2812public 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
 49    public OneCNomenclatureMapStore(IServiceScopeFactory scopeFactory)
 50    {
 51        _scopeFactory = scopeFactory;
 52    }
 53
 54    public async Task<IReadOnlyList<Services.ApiServices._1CApi.Nomenclature.MapRow>> LoadAllAsync(CancellationToken can
 55    {
 56        await using var scope = _scopeFactory.CreateAsyncScope();
 57        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 58
 59        return await db.OneCNomenclatureMaps
 60            .AsNoTracking()
 61            .Select(m => new Services.ApiServices._1CApi.Nomenclature.MapRow(
 62                m.ArticleNorm, m.ProducerNorm, m.OneCProducerName, m.OneCRefKey, m.OneCArticleRaw, m.Source))
 63            .ToListAsync(cancellationToken);
 64    }
 65
 66    public async Task<OneCMapEntry?> FindAsync(string articleNorm, string producerNorm, CancellationToken cancellationTo
 67    {
 68        if (string.IsNullOrEmpty(articleNorm))
 69            return null;
 70
 71        await using var scope = _scopeFactory.CreateAsyncScope();
 72        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 73
 74        var row = await db.OneCNomenclatureMaps
 75            .AsNoTracking()
 76            .FirstOrDefaultAsync(m => m.ArticleNorm == articleNorm && m.ProducerNorm == producerNorm, cancellationToken)
 77
 78        return row == null ? null : ToEntry(row);
 79    }
 80
 81    public async Task<IReadOnlyList<OneCMapEntry>> FindByArticleAsync(string articleNorm, CancellationToken cancellation
 82    {
 83        if (string.IsNullOrEmpty(articleNorm))
 84            return [];
 85
 86        await using var scope = _scopeFactory.CreateAsyncScope();
 87        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 88
 89        var rows = await db.OneCNomenclatureMaps
 90            .AsNoTracking()
 91            .Where(m => m.ArticleNorm == articleNorm)
 92            .ToListAsync(cancellationToken);
 93
 94        return rows.Select(ToEntry).ToList();
 95    }
 96
 97    public async Task<IReadOnlyDictionary<Guid, OneCMapEntry>> FindByRefKeysAsync(
 98        IReadOnlyCollection<Guid> refKeys,
 99        CancellationToken cancellationToken = default)
 100    {
 101        if (refKeys.Count == 0)
 102            return new Dictionary<Guid, OneCMapEntry>();
 103
 104        await using var scope = _scopeFactory.CreateAsyncScope();
 105        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 106
 107        var distinct = refKeys.Distinct().ToList();
 108        var result = new Dictionary<Guid, OneCMapEntry>(distinct.Count);
 109
 110        // Порциями, чтобы не упереться в лимит параметров запроса при большой странице.
 111        const int chunkSize = 200;
 112        for (var offset = 0; offset < distinct.Count; offset += chunkSize)
 113        {
 114            var chunk = distinct.Skip(offset).Take(chunkSize).ToList();
 115            var rows = await db.OneCNomenclatureMaps
 116                .AsNoTracking()
 117                .Where(m => chunk.Contains(m.OneCRefKey))
 118                .ToListAsync(cancellationToken);
 119
 120            foreach (var row in rows)
 121                result.TryAdd(row.OneCRefKey, ToEntry(row));
 122        }
 123
 124        return result;
 125    }
 126
 127    public async Task UpsertAsync(OneCMapEntry entry, string? rawArticle, string source, CancellationToken cancellationT
 128        => 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    {
 139        if (items.Count == 0)
 140            return 0;
 141
 142        await using var scope = _scopeFactory.CreateAsyncScope();
 143        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 144
 145        var affected = 0;
 146        const int chunkSize = 500;
 147
 148        for (var offset = 0; offset < items.Count; offset += chunkSize)
 149        {
 150            var chunk = items.Skip(offset).Take(chunkSize).ToList();
 151            var parameters = new List<object>();
 152            var rows = new List<string>(chunk.Count);
 153
 154            foreach (var (entry, rawArticle) in chunk)
 155            {
 156                var values = new[]
 157                {
 158                    Truncate(entry.ArticleNorm, 128) ?? string.Empty,
 159                    Truncate(entry.ProducerNorm, 128) ?? string.Empty,
 160                    entry.RefKey.ToString(),
 161                    Truncate(rawArticle, 191),
 162                    Truncate(entry.ProducerName, 191),
 163                    Truncate(source, 32) ?? string.Empty,
 164                };
 165
 166                var (placeholders, _) = BuildPlaceholders(values, parameters);
 167                rows.Add($"({string.Join(", ", placeholders)}, UTC_TIMESTAMP(), UTC_TIMESTAMP())");
 168            }
 169
 170            var sql = $"""
 171                INSERT INTO `OneCNomenclatureMaps`
 172                    (`ArticleNorm`, `ProducerNorm`, `OneCRefKey`, `OneCArticleRaw`, `OneCProducerName`, `Source`, `Creat
 173                VALUES {string.Join(", ", rows)}
 174                ON DUPLICATE KEY UPDATE
 175                    `OneCRefKey` = VALUES(`OneCRefKey`),
 176                    `OneCArticleRaw` = VALUES(`OneCArticleRaw`),
 177                    `OneCProducerName` = VALUES(`OneCProducerName`),
 178                    `Source` = VALUES(`Source`),
 179                    `UpdatedAt` = UTC_TIMESTAMP()
 180                """;
 181
 182            affected += await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 183        }
 184
 185        return affected;
 186    }
 187
 188    public async Task<IReadOnlyDictionary<(string ArticleNorm, string ProducerNorm), Guid>> GetAllKeysAsync(
 189        CancellationToken cancellationToken = default)
 190    {
 191        await using var scope = _scopeFactory.CreateAsyncScope();
 192        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 193
 194        var rows = await db.OneCNomenclatureMaps
 195            .AsNoTracking()
 196            .Select(m => new { m.ArticleNorm, m.ProducerNorm, m.OneCRefKey })
 197            .ToListAsync(cancellationToken);
 198
 199        var result = new Dictionary<(string, string), Guid>(rows.Count);
 200        foreach (var row in rows)
 201            result[(row.ArticleNorm, row.ProducerNorm)] = row.OneCRefKey;
 202        return result;
 203    }
 204
 205    public async Task<OneCProducerAliasEntry?> FindProducerAliasAsync(string aliasNorm, CancellationToken cancellationTo
 206    {
 207        if (string.IsNullOrEmpty(aliasNorm))
 208            return null;
 209
 210        await using var scope = _scopeFactory.CreateAsyncScope();
 211        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 212
 213        var row = await db.OneCProducerAliases
 214            .AsNoTracking()
 215            .FirstOrDefaultAsync(a => a.AliasNorm == aliasNorm, cancellationToken);
 216
 217        return row == null ? null : new OneCProducerAliasEntry(row.CanonicalRefKey, row.CanonicalName);
 218    }
 219
 220    public async Task UpsertProducerAliasAsync(
 221        string aliasNorm,
 222        Guid canonicalRefKey,
 223        string? canonicalName,
 224        string source,
 225        CancellationToken cancellationToken = default)
 226    {
 227        if (string.IsNullOrEmpty(aliasNorm))
 228            return;
 229
 230        await using var scope = _scopeFactory.CreateAsyncScope();
 231        var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
 232
 233        // Как и в BulkUpsertAsync: NULL — литералом, DBNull через ExecuteSqlRawAsync не проходит.
 234        var values = new[]
 235        {
 236            Truncate(aliasNorm, 128) ?? string.Empty,
 237            canonicalRefKey.ToString(),
 238            Truncate(canonicalName, 191),
 239            Truncate(source, 32) ?? string.Empty,
 240        };
 241        var (placeholders, parameters) = BuildPlaceholders(values);
 242
 243        var sql = $"INSERT INTO `OneCProducerAliases` "
 244                  + "(`AliasNorm`, `CanonicalRefKey`, `CanonicalName`, `Source`, `CreatedAt`) "
 245                  + $"VALUES ({string.Join(", ", placeholders)}, UTC_TIMESTAMP()) "
 246                  + "ON DUPLICATE KEY UPDATE "
 247                  + "`CanonicalRefKey` = VALUES(`CanonicalRefKey`), "
 248                  + "`CanonicalName` = VALUES(`CanonicalName`)";
 249
 250        await db.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 251    }
 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    {
 263        parameters ??= [];
 264        var placeholders = new List<string>(values.Count);
 265        foreach (var value in values)
 266        {
 267            if (value == null)
 268            {
 269                placeholders.Add("NULL");
 270                continue;
 271            }
 272            placeholders.Add($"{{{parameters.Count}}}");
 273            parameters.Add(value);
 274        }
 275        return (placeholders, parameters);
 276    }
 277
 278    private static OneCMapEntry ToEntry(OneCNomenclatureMap row)
 279        => new(row.OneCRefKey, row.ArticleNorm, row.ProducerNorm, row.OneCProducerName);
 280
 281    private static string? Truncate(string? value, int maxLength)
 282        => value != null && value.Length > maxLength ? value[..maxLength] : value;
 283}

Methods/Properties

get_RefKey()