< Summary

Information
Class: Services.ApiServices._1CApi.OData.OneCODataReferenceService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataReferenceService.cs
Line coverage
31%
Covered lines: 70
Uncovered lines: 152
Coverable lines: 222
Total lines: 388
Line coverage: 31.5%
Branch coverage
18%
Covered branches: 14
Total branches: 76
Branch coverage: 18.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Microsoft.Extensions.Caching.Memory;
 3using Microsoft.Extensions.Logging;
 4using Services.ApiServices._1CApi.OData.Models;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8public interface IOneCODataReferenceService
 9{
 10    Task<Guid?> GetOrganizationGuidByInnAsync(string inn);
 11    Task<List<ODataOrganization>> GetOrganizationsAsync();
 12    Task<Guid?> GetCounterpartyGuidByInnAsync(string inn);
 13    /// <summary>
 14    /// Ищет GUID контрагента по ИНН. Сначала кеш (DeletionMark=false),
 15    /// затем прямой OData $filter (находит в т.ч. помеченных на удаление).
 16    /// </summary>
 17    Task<Guid?> FindCounterpartyGuidByInnAsync(string inn);
 18    /// <summary>Возвращает контрагента по GUID: из кеша или прямым GET-запросом.</summary>
 19    Task<ODataCounterparty?> GetCounterpartyByGuidAsync(Guid guid);
 20    Task<Guid?> GetWarehouseGuidByNameAsync(string name);
 21    Task<Guid?> GetNomenclatureGuidByArticleAsync(string article);
 22    Task<Guid?> GetCountryGuidByNameAsync(string countryName);
 23    Task<Guid?> GetGtdGuidByNumberAsync(string gtdNumber);
 24    Task<Guid?> GetVatRateGuidByRateAsync(int? vatRatePercent);
 25    Task<Guid?> GetNoVatRateGuidAsync();
 26    Task<List<ODataWarehouse>> GetWarehousesAsync();
 27    Task<List<ODataContract>> GetAllContractsAsync();
 28    Task<List<ODataContract>> GetContractsByCounterpartyAsync(Guid counterpartyGuid);
 29    Task<List<ODataCounterparty>> GetCounterpartiesAsync();
 30}
 31
 32public class OneCODataReferenceService : IOneCODataReferenceService
 33{
 34    private readonly IOneCODataClient _client;
 35    private readonly IMemoryCache _cache;
 36    private readonly ILogger<OneCODataReferenceService> _logger;
 37
 138    private static readonly TimeSpan OrgTtl = TimeSpan.FromMinutes(60);
 139    private static readonly TimeSpan CounterpartyTtl = TimeSpan.FromMinutes(30);
 140    private static readonly TimeSpan WarehouseTtl = TimeSpan.FromMinutes(60);
 141    private static readonly TimeSpan NomenclatureTtl = TimeSpan.FromMinutes(30);
 142    private static readonly TimeSpan CountryTtl = TimeSpan.FromMinutes(60);
 143    private static readonly TimeSpan GtdTtl = TimeSpan.FromMinutes(20);
 144    private static readonly TimeSpan VatRateTtl = TimeSpan.FromMinutes(120);
 145    private static readonly TimeSpan ContractTtl = TimeSpan.FromMinutes(20);
 46
 47    private const string OrgCacheKey = "odata_orgs";
 48    private const string CounterpartyCacheKey = "odata_counterparties";
 49    private const string WarehouseCacheKey = "odata_warehouses";
 50    private const string VatRateCacheKey = "odata_vatrates";
 51    private const string ContractAllCacheKey = "odata_contracts_all";
 52    private const string CountryCacheKey = "odata_countries";
 53    private const string GtdCacheKey = "odata_gtd_numbers";
 54
 155    private readonly SemaphoreSlim _orgSemaphore = new(1, 1);
 156    private readonly SemaphoreSlim _counterpartySemaphore = new(1, 1);
 157    private readonly SemaphoreSlim _warehouseSemaphore = new(1, 1);
 158    private readonly SemaphoreSlim _vatRateSemaphore = new(1, 1);
 159    private readonly SemaphoreSlim _contractSemaphore = new(1, 1);
 160    private readonly SemaphoreSlim _countrySemaphore = new(1, 1);
 161    private readonly SemaphoreSlim _gtdSemaphore = new(1, 1);
 62
 163    public OneCODataReferenceService(
 164        IOneCODataClient client,
 165        IMemoryCache cache,
 166        ILogger<OneCODataReferenceService> logger)
 67    {
 168        _client = client;
 169        _cache = cache;
 170        _logger = logger;
 171    }
 72
 73    public async Task<Guid?> GetOrganizationGuidByInnAsync(string inn)
 74    {
 075        if (string.IsNullOrWhiteSpace(inn)) return null;
 076        var trimmed = inn.Trim();
 077        var orgs = await GetOrganizationsAsync();
 078        return orgs.FirstOrDefault(o =>
 079            string.Equals(o.Inn?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase))?.RefKey;
 080    }
 81
 82    public async Task<Guid?> GetCounterpartyGuidByInnAsync(string inn)
 83    {
 184        return await FindCounterpartyGuidByInnAsync(inn);
 185    }
 86
 87    public async Task<Guid?> GetWarehouseGuidByNameAsync(string name)
 88    {
 089        if (string.IsNullOrWhiteSpace(name)) return null;
 090        var warehouses = await GetWarehousesAsync();
 091        return warehouses.FirstOrDefault(w =>
 092            string.Equals(w.Description, name.Trim(), StringComparison.OrdinalIgnoreCase))?.RefKey;
 093    }
 94
 95    public async Task<Guid?> GetNomenclatureGuidByArticleAsync(string article)
 96    {
 097        if (string.IsNullOrWhiteSpace(article)) return null;
 098        var trimmed = article.Trim();
 099        var cleaned = CleanArticle(trimmed);
 0100        var cacheKey = $"odata_nom_{cleaned}";
 101
 0102        return await _cache.GetOrCreateAsync<Guid?>(cacheKey, async entry =>
 0103        {
 0104            entry.AbsoluteExpirationRelativeToNow = NomenclatureTtl;
 0105            try
 0106            {
 0107                // Используем $filter по артикулу — один запрос вместо полной загрузки каталога
 0108                var filter = $"Артикул eq '{EscapeOData(trimmed)}' and IsFolder eq false";
 0109                var items = await _client.GetSinglePageAsync<ODataNomenclature>(
 0110                    "Catalog_Номенклатура",
 0111                    top: 10,
 0112                    queryParams: new Dictionary<string, string>
 0113                    {
 0114                        ["$select"] = "Ref_Key,Артикул",
 0115                        ["$filter"] = filter
 0116                    });
 0117
 0118                // Точное совпадение после нормализации
 0119                var found = items.FirstOrDefault(i =>
 0120                    string.Equals(CleanArticle(i.Article ?? ""), cleaned, StringComparison.OrdinalIgnoreCase));
 0121
 0122                return (Guid?)found?.RefKey;
 0123            }
 0124            catch (Exception ex)
 0125            {
 0126                _logger.LogError(ex, "[OData] Ошибка поиска номенклатуры '{Article}'", article);
 0127                return null;
 0128            }
 0129        });
 0130    }
 131
 132    public async Task<Guid?> GetCountryGuidByNameAsync(string countryName)
 133    {
 0134        if (string.IsNullOrWhiteSpace(countryName)) return null;
 0135        var normalized = countryName.Trim();
 0136        var countries = await GetCountriesAsync();
 0137        return countries.FirstOrDefault(country =>
 0138            string.Equals(country.Description?.Trim(), normalized, StringComparison.OrdinalIgnoreCase))?.RefKey;
 0139    }
 140
 141    public async Task<Guid?> GetGtdGuidByNumberAsync(string gtdNumber)
 142    {
 0143        if (string.IsNullOrWhiteSpace(gtdNumber)) return null;
 0144        var normalized = gtdNumber.Trim();
 0145        var gtdNumbers = await GetGtdNumbersAsync();
 0146        return gtdNumbers.FirstOrDefault(gtd =>
 0147            string.Equals(gtd.RegistrationNumber?.Trim(), normalized, StringComparison.OrdinalIgnoreCase))?.RefKey;
 0148    }
 149
 150    private static string EscapeOData(string value)
 2151        => value.Replace("'", "''");
 152
 153    public async Task<Guid?> GetVatRateGuidByRateAsync(int? vatRatePercent)
 154    {
 0155        if (!vatRatePercent.HasValue) return await GetNoVatRateGuidAsync();
 0156        var rates = await GetVatRatesAsync();
 0157        var description = $"{vatRatePercent}%";
 0158        return rates.FirstOrDefault(r =>
 0159            string.Equals(r.Description, description, StringComparison.OrdinalIgnoreCase))?.RefKey;
 0160    }
 161
 162    public async Task<Guid?> GetNoVatRateGuidAsync()
 163    {
 0164        var rates = await GetVatRatesAsync();
 0165        return rates.FirstOrDefault(r =>
 0166            (r.Description ?? "").Contains("без", StringComparison.OrdinalIgnoreCase) ||
 0167            (r.Description ?? "").Contains("0%", StringComparison.OrdinalIgnoreCase))?.RefKey;
 0168    }
 169
 170    public Task<List<ODataWarehouse>> GetWarehousesAsync()
 0171        => GetCachedListAsync(WarehouseCacheKey, WarehouseTtl, _warehouseSemaphore,
 0172            () => _client.GetListAsync<ODataWarehouse>(
 0173                "Catalog_СтруктурныеЕдиницы",
 0174                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }),
 0175            "Ошибка загрузки складов");
 176
 177    public Task<List<ODataCountry>> GetCountriesAsync()
 0178        => GetCachedListAsync(CountryCacheKey, CountryTtl, _countrySemaphore,
 0179            () => _client.GetListAsync<ODataCountry>(
 0180                "Catalog_СтраныМира",
 0181                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }),
 0182            "Ошибка загрузки стран происхождения");
 183
 184    private Task<List<ODataGtdCode>> GetGtdNumbersAsync()
 0185        => GetCachedListAsync(GtdCacheKey, GtdTtl, _gtdSemaphore,
 0186            () => _client.GetListAsync<ODataGtdCode>(
 0187                "Catalog_НомераГТД",
 0188                new Dictionary<string, string> { ["$select"] = "Ref_Key,РегистрационныйНомер" }),
 0189            "Ошибка загрузки номеров ГТД");
 190
 191    public Task<List<ODataContract>> GetAllContractsAsync()
 0192        => GetAllContractsInternalAsync();
 193
 194    public async Task<List<ODataContract>> GetContractsByCounterpartyAsync(Guid counterpartyGuid)
 195    {
 0196        var all = await GetAllContractsInternalAsync();
 0197        return all.Where(c => c.Owner == counterpartyGuid).ToList();
 0198    }
 199
 200    public async Task<List<ODataCounterparty>> GetCounterpartiesAsync()
 0201        => await GetAllCounterpartiesAsync();
 202
 203    public async Task<Guid?> FindCounterpartyGuidByInnAsync(string inn)
 204    {
 1205        if (string.IsNullOrWhiteSpace(inn)) return null;
 1206        var trimmed = inn.Trim();
 207
 208        // Сначала общий кеш: в форме Studio2 поле Supplier.INNCode хранит ИНН или регистрационный номер.
 1209        var cached = await GetAllCounterpartiesAsync();
 1210        var found = cached.FirstOrDefault(c =>
 1211            string.Equals(c.Inn?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase) ||
 1212            string.Equals(c.RegistrationNumber?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
 1213        if (found != null) return found.RefKey;
 214
 215        // Прямые запросы также находят контрагентов, отсутствующих в кеше активных записей.
 5216        foreach (var field in new[] { "ИНН", "РегистрационныйНомер" })
 217        {
 218            try
 219            {
 2220                var items = await _client.GetSinglePageAsync<ODataCounterparty>(
 2221                    "Catalog_Контрагенты",
 2222                    top: 5,
 2223                    queryParams: new Dictionary<string, string>
 2224                    {
 2225                        ["$select"] = field == "ИНН"
 2226                            ? "Ref_Key,Description,ИНН"
 2227                            : "Ref_Key,Description,ИНН,РегистрационныйНомер",
 2228                        ["$filter"] = $"{field} eq '{EscapeOData(trimmed)}' and IsFolder eq false"
 2229                    });
 2230                var match = items.FirstOrDefault(c =>
 3231                    string.Equals(c.Inn?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase) ||
 3232                    string.Equals(c.RegistrationNumber?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase));
 3233                if (match != null) return match.RefKey;
 1234            }
 0235            catch (Exception ex)
 236            {
 0237                _logger.LogWarning(ex, "[OData] Поиск контрагента по полю {Field} недоступен для идентификатора '{Identi
 0238                    field, trimmed);
 0239            }
 1240        }
 241
 0242        return null;
 1243    }
 244
 245    public async Task<ODataCounterparty?> GetCounterpartyByGuidAsync(Guid guid)
 246    {
 247        // Кеш сначала
 0248        var cached = await GetAllCounterpartiesAsync();
 0249        var found = cached.FirstOrDefault(c => c.RefKey == guid);
 0250        if (found != null) return found;
 251
 252        // Прямой GET если не в кеше (например, контрагент помечен на удаление)
 253        try
 254        {
 0255            var counterparty = await _client.GetByGuidAsync<ODataCounterparty>(
 0256                "Catalog_Контрагенты",
 0257                guid,
 0258                select: "Ref_Key,Description,ИНН,РегистрационныйНомер");
 0259            if (counterparty != null) return counterparty;
 260
 0261            return await _client.GetByGuidAsync<ODataCounterparty>(
 0262                "Catalog_Контрагенты",
 0263                guid,
 0264                select: "Ref_Key,Description,ИНН");
 265        }
 0266        catch (Exception ex)
 267        {
 0268            _logger.LogError(ex, "[OData] Ошибка GetCounterpartyByGuidAsync '{Guid}'", guid);
 0269            return null;
 270        }
 0271    }
 272
 273    // ─── Внутренние методы загрузки с кешем ───────────────────────────────────
 274
 275    public Task<List<ODataOrganization>> GetOrganizationsAsync()
 0276        => GetCachedListAsync(OrgCacheKey, OrgTtl, _orgSemaphore,
 0277            () => _client.GetListAsync<ODataOrganization>(
 0278                "Catalog_Организации",
 0279                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description,ИНН,DeletionMark" }),
 0280            "Ошибка загрузки организаций");
 281
 282    private Task<List<ODataCounterparty>> GetAllCounterpartiesAsync()
 1283        => GetCachedListAsync(CounterpartyCacheKey, CounterpartyTtl, _counterpartySemaphore,
 1284            LoadCounterpartiesAsync,
 1285            "Ошибка загрузки контрагентов");
 286
 287    private async Task<List<ODataCounterparty>> LoadCounterpartiesAsync()
 288    {
 289        try
 290        {
 1291            return await _client.GetListAsync<ODataCounterparty>(
 1292                "Catalog_Контрагенты",
 1293                new Dictionary<string, string>
 1294                {
 1295                    ["$select"] = "Ref_Key,Description,ИНН,РегистрационныйНомер",
 1296                    ["$filter"] = "IsFolder eq false and DeletionMark eq false"
 1297                });
 298        }
 299        catch (Exception ex)
 300        {
 0301            _logger.LogWarning(ex,
 0302                "[OData] РегистрационныйНомер недоступен в списке контрагентов; загружаю ИНН без него");
 0303            return await _client.GetListAsync<ODataCounterparty>(
 0304                "Catalog_Контрагенты",
 0305                new Dictionary<string, string>
 0306                {
 0307                    ["$select"] = "Ref_Key,Description,ИНН",
 0308                    ["$filter"] = "IsFolder eq false and DeletionMark eq false"
 0309                });
 310        }
 1311    }
 312
 313    private Task<List<ODataVatRate>> GetVatRatesAsync()
 0314        => GetCachedListAsync(VatRateCacheKey, VatRateTtl, _vatRateSemaphore,
 0315            () => _client.GetListAsync<ODataVatRate>(
 0316                "Catalog_СтавкиНДС",
 0317                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }),
 0318            "Ошибка загрузки ставок НДС");
 319
 320    private Task<List<ODataContract>> GetAllContractsInternalAsync()
 0321        => GetCachedListAsync(ContractAllCacheKey, ContractTtl, _contractSemaphore,
 0322            LoadContractsAsync,
 0323            "Ошибка загрузки договоров");
 324
 325    /// <summary>
 326    /// Договоры вместе с организацией (нужна для фильтра «договоры организации»). Если конфигурация 1С не отдаёт
 327    /// поле Организация_Key — один раз откатываемся на прежний набор полей, а не оставляем список пустым.
 328    /// </summary>
 329    private async Task<List<ODataContract>> LoadContractsAsync()
 330    {
 331        try
 332        {
 0333            return await _client.GetListAsync<ODataContract>(
 0334                "Catalog_ДоговорыКонтрагентов",
 0335                new Dictionary<string, string>
 0336                {
 0337                    ["$select"] = "Ref_Key,Description,Owner,Организация_Key",
 0338                    ["$orderby"] = "Ref_Key"
 0339                });
 340        }
 341        catch (Exception ex)
 342        {
 0343            _logger.LogWarning(ex, "[OData] Договоры без Организация_Key: поле недоступно, фильтр по организации отключё
 0344            return await _client.GetListAsync<ODataContract>(
 0345                "Catalog_ДоговорыКонтрагентов",
 0346                new Dictionary<string, string>
 0347                {
 0348                    ["$select"] = "Ref_Key,Description,Owner",
 0349                    ["$orderby"] = "Ref_Key"
 0350                });
 351        }
 0352    }
 353
 354    private async Task<List<T>> GetCachedListAsync<T>(
 355        string cacheKey,
 356        TimeSpan ttl,
 357        SemaphoreSlim semaphore,
 358        Func<Task<List<T>>> factory,
 359        string errorContext)
 360    {
 1361        if (_cache.TryGetValue(cacheKey, out List<T>? cached))
 0362            return cached!;
 363
 1364        await semaphore.WaitAsync();
 365        try
 366        {
 1367            if (_cache.TryGetValue(cacheKey, out cached))
 0368                return cached!;
 369
 1370            var result = await factory();
 1371            _cache.Set(cacheKey, result, ttl);
 1372            return result;
 373        }
 0374        catch (Exception ex)
 375        {
 0376            _logger.LogError(ex, "[OData] {Context}", errorContext);
 0377            return new List<T>();
 378        }
 379        finally
 380        {
 1381            semaphore.Release();
 382        }
 1383    }
 384
 385
 386    private static string CleanArticle(string article)
 0387        => Regex.Replace(article, @"[^a-zA-Zа-яА-Я0-9]", "").ToUpperInvariant();
 388}