< Summary

Information
Class: Services.ApiServices.OneCDataManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OneCDataManager.cs
Line coverage
49%
Covered lines: 317
Uncovered lines: 323
Coverable lines: 640
Total lines: 975
Line coverage: 49.5%
Branch coverage
39%
Covered branches: 114
Total branches: 288
Branch coverage: 39.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OneCDataManager.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Microsoft.Extensions.Logging;
 3using Newtonsoft.Json.Linq;
 4using Services.ApiServices._1CApi.Models;
 5using Services.ApiServices._1CApi.OData;
 6using Services.ApiServices._1CApi.OData.Models;
 7
 8namespace Services.ApiServices;
 9
 10/// <summary>
 11/// Данные справочников 1С для UI (дропдауны складов, договоров, поставщиков, стран, ГТД).
 12/// Все методы реализованы через 1С Fresh OData API.
 13/// </summary>
 14public class OneCDataManager
 15{
 16    private readonly IOneCODataReferenceService _referenceService;
 17    private readonly IOneCODataClient _odataClient;
 18    private readonly ILogger<OneCDataManager> _logger;
 19
 20    private static volatile Dictionary<(Guid, Guid), decimal>? _stockCache;
 21    private static volatile bool _stockCacheValid;
 122    private static DateTime _stockCacheExpiry = DateTime.MinValue;
 123    private static readonly SemaphoreSlim _stockSemaphore = new(1, 1);
 124    private static readonly TimeSpan StockCacheTtl = TimeSpan.FromMinutes(5);
 25
 5626    public OneCDataManager(
 5627        IOneCODataReferenceService referenceService,
 5628        IOneCODataClient odataClient,
 5629        ILogger<OneCDataManager> logger)
 30    {
 5631        _referenceService = referenceService;
 5632        _odataClient = odataClient;
 5633        _logger = logger;
 5634    }
 35
 36    /// <summary>Список складов для дропдауна. Link = Ref_Key (GUID).</summary>
 37    public async Task<List<WarehouseResponse>> GetWarehouseListAsync()
 38    {
 39        try
 40        {
 041            var warehouses = await _referenceService.GetWarehousesAsync();
 042            return warehouses
 043                .Select(w => new WarehouseResponse
 044                {
 045                    Name = w.Description,
 046                    Link = w.RefKey.ToString()
 047                })
 048                .OrderBy(w => w.Name)
 049                .ToList();
 50        }
 051        catch (Exception ex)
 52        {
 053            _logger.LogWarning(ex, "[OData] Ошибка GetWarehouseListAsync");
 054            return new List<WarehouseResponse>();
 55        }
 056    }
 57
 58    /// <summary>
 59    /// Список договоров для дропдауна через OData 1C Fresh.
 60    /// Если <paramref name="supplierInn"/> указан — целевой двухшаговый запрос:
 61    ///   1. Catalog_Контрагенты: найти GUID по ИНН или регистрационному номеру.
 62    ///   2. Catalog_ДоговорыКонтрагентов: загрузить договоры по Owner = GUID.
 63    /// Без ИНН — загружает все договоры (fallback).
 64    /// <paramref name="organizationInn"/> (только вместе с ИНН контрагента) оставляет договоры этой организации
 65    /// и договоры без привязки к организации.
 66    /// </summary>
 67    public async Task<List<TreatyResponse>> GetTreatyListAsync(string? supplierInn = null, string? organizationInn = nul
 68    {
 69        try
 70        {
 471            Guid? orgGuid = string.IsNullOrWhiteSpace(organizationInn)
 472                ? null
 473                : await _referenceService.GetOrganizationGuidByInnAsync(organizationInn);
 74
 475            if (!string.IsNullOrWhiteSpace(supplierInn))
 76            {
 377                var inn = supplierInn.Trim();
 78
 79                // Шаг 1: Catalog_Контрагенты → GUID по ИНН
 380                var counterpartyGuid = await _referenceService.FindCounterpartyGuidByInnAsync(inn);
 381                if (counterpartyGuid == null)
 082                    return new List<TreatyResponse>();
 83
 84                // Шаг 2: Catalog_ДоговорыКонтрагентов → договоры по Owner
 385                var contracts = await _referenceService.GetContractsByCounterpartyAsync(counterpartyGuid.Value);
 386                contracts = FilterByOrganization(contracts, orgGuid);
 387                if (!contracts.Any())
 088                    return new List<TreatyResponse>();
 89
 90                // Наименование контрагента для отображения (кеш → GET)
 391                var counterparty = await _referenceService.GetCounterpartyByGuidAsync(counterpartyGuid.Value);
 392                var partnerName = counterparty?.Description ?? string.Empty;
 93
 394                return contracts
 1195                    .Select(c => new TreatyResponse
 1196                    {
 1197                        Link = c.RefKey.ToString(),
 1198                        Name = c.Description,
 1199                        Partner = partnerName,
 11100                        Inn = inn
 11101                    })
 11102                    .OrderBy(t => t.Name)
 3103                    .ToList();
 104            }
 105
 106            // Fallback: все договоры без фильтра по ИНН
 1107            var allCounterparties = await _referenceService.GetCounterpartiesAsync();
 108            // 1С может вернуть дублирующиеся Ref_Key — DistinctBy защищает ToDictionary от ArgumentException
 1109            var uniqueCounterparties = allCounterparties.DistinctBy(c => c.RefKey).ToList();
 1110            var innByGuid = uniqueCounterparties.ToDictionary(c => c.RefKey, c => c.Inn);
 1111            var nameByGuid = uniqueCounterparties.ToDictionary(c => c.RefKey, c => c.Description);
 1112            var allContracts = FilterByOrganization(await _referenceService.GetAllContractsAsync(), orgGuid);
 113
 1114            return allContracts
 1115                .Select(c =>
 1116                {
 3117                    innByGuid.TryGetValue(c.Owner, out var contractInn);
 3118                    nameByGuid.TryGetValue(c.Owner, out var name);
 3119                    return new TreatyResponse
 3120                    {
 3121                        Link = c.RefKey.ToString(),
 3122                        Name = c.Description,
 3123                        Partner = name ?? string.Empty,
 3124                        Inn = contractInn ?? string.Empty
 3125                    };
 1126                })
 6127                .OrderBy(t => t.Partner).ThenBy(t => t.Name)
 1128                .ToList();
 129        }
 0130        catch (Exception ex)
 131        {
 0132            _logger.LogWarning(ex, "[OData] Ошибка GetTreatyListAsync(inn={Inn})", supplierInn);
 0133            return new List<TreatyResponse>();
 134        }
 4135    }
 136
 137    /// <summary>Договоры организации и договоры без привязки к организации; без организации (или неизвестной) — без фил
 138    private static List<ODataContract> FilterByOrganization(List<ODataContract> contracts, Guid? orgGuid)
 4139        => orgGuid == null
 4140            ? contracts
 12141            : contracts.Where(c => c.OrganizationKey is null || c.OrganizationKey == Guid.Empty || c.OrganizationKey == 
 142
 143    /// <summary>Организации 1С (не помеченные на удаление, с ИНН) для выбора организации документа.</summary>
 144    public async Task<List<OrganizationResponse>> GetOrganizationListAsync()
 145    {
 146        try
 147        {
 1148            var organizations = await _referenceService.GetOrganizationsAsync();
 1149            return organizations
 4150                .Where(o => !o.DeletionMark && !string.IsNullOrWhiteSpace(o.Inn))
 2151                .Select(o => new OrganizationResponse { Link = o.RefKey.ToString(), Name = o.Description, Inn = o.Inn!.T
 2152                .OrderBy(o => o.Name)
 1153                .ToList();
 154        }
 0155        catch (Exception ex)
 156        {
 0157            _logger.LogWarning(ex, "[OData] Ошибка GetOrganizationListAsync");
 0158            return new List<OrganizationResponse>();
 159        }
 1160    }
 161
 162    /// <summary>Список поставщиков 1С. Link = Ref_Key (GUID), Unp = ИНН или регистрационный номер.</summary>
 163    public async Task<List<SupplierResponse>> GetSupplierListAsync()
 164    {
 165        try
 166        {
 0167            var counterparties = await _referenceService.GetCounterpartiesAsync();
 0168            return counterparties
 0169                .Select(c => new SupplierResponse
 0170                {
 0171                    Name = c.Description,
 0172                    FullName = c.Description,
 0173                    Unp = c.Identifier,
 0174                    Link = c.RefKey.ToString()
 0175                })
 0176                .OrderBy(s => s.Name)
 0177                .ToList();
 178        }
 0179        catch (Exception ex)
 180        {
 0181            _logger.LogWarning(ex, "[OData] Ошибка GetSupplierListAsync");
 0182            return new List<SupplierResponse>();
 183        }
 0184    }
 185
 186    /// <summary>
 187    /// Поиск контрагентов по ИНН или названию. Используется для live-автодополнения в UI.
 188    /// </summary>
 189    public async Task<List<ODataCounterparty>> SearchCounterpartiesAsync(string? query, int limit = 15)
 190    {
 3191        var all = await _referenceService.GetCounterpartiesAsync();
 3192        if (string.IsNullOrWhiteSpace(query))
 0193            return all.Take(limit).ToList();
 3194        var q = query.Trim().ToLowerInvariant();
 3195        var results = all
 3196            .Where(c =>
 0197                (c.Inn?.ToLowerInvariant().Contains(q) == true) ||
 0198                (c.RegistrationNumber?.ToLowerInvariant().Contains(q) == true) ||
 0199                (c.Description?.ToLowerInvariant().Contains(q) == true))
 3200            .Take(limit)
 3201            .ToList();
 202
 3203        if (q.All(char.IsDigit) &&
 3204            !results.Any(c => string.Equals(c.Identifier?.Trim(), query.Trim(), StringComparison.OrdinalIgnoreCase)))
 205        {
 2206            var counterpartyGuid = await _referenceService.FindCounterpartyGuidByInnAsync(query.Trim());
 2207            if (counterpartyGuid.HasValue)
 208            {
 2209                var counterparty = await _referenceService.GetCounterpartyByGuidAsync(counterpartyGuid.Value);
 2210                if (counterparty != null &&
 2211                    string.Equals(counterparty.Identifier?.Trim(), query.Trim(), StringComparison.OrdinalIgnoreCase) &&
 2212                    results.All(c => c.RefKey != counterparty.RefKey))
 213                {
 2214                    results.Insert(0, counterparty);
 2215                    if (results.Count > limit)
 0216                        results.RemoveAt(results.Count - 1);
 217                }
 2218            }
 219        }
 220
 3221        return results;
 3222    }
 223
 224    public async Task<List<ODataOrganization>> SearchOrganizationsAsync(string? query, int limit = 15)
 225    {
 0226        var all = await _referenceService.GetOrganizationsAsync();
 0227        if (string.IsNullOrWhiteSpace(query))
 0228            return all.Where(o => !string.IsNullOrWhiteSpace(o.Inn)).Take(limit).ToList();
 0229        var q = query.Trim().ToLowerInvariant();
 0230        return all
 0231            .Where(o =>
 0232                (o.Inn?.ToLowerInvariant().Contains(q) == true) ||
 0233                (o.Description?.ToLowerInvariant().Contains(q) == true))
 0234            .Take(limit)
 0235            .ToList();
 0236    }
 237
 238    public async Task<List<string>> GetCountryListAsync()
 239    {
 240        try
 241        {
 5242            var countries = await _odataClient.GetListAsync<ODataCountry>(
 5243                "Catalog_СтраныМира",
 5244                new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" });
 4245            return countries
 10246                .Select(c => c.Description?.Trim())
 10247                .Where(name => !string.IsNullOrWhiteSpace(name))
 4248                .Distinct(StringComparer.OrdinalIgnoreCase)
 5249                .OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
 4250                .ToList()!;
 251        }
 1252        catch (Exception ex)
 253        {
 1254            _logger.LogWarning(ex, "[OData] Ошибка GetCountryListAsync");
 1255            return new List<string>();
 256        }
 5257    }
 258
 259    public async Task<List<string>> SearchGtdCodesAsync(string? query, int limit = 20)
 260    {
 13261        if (limit <= 0) limit = 20;
 11262        if (limit > 100) limit = 100;
 10263        var trimmed = (query ?? string.Empty).Trim();
 264
 265        try
 266        {
 267            // Приходная накладная сохраняет ссылку на Catalog_НомераГТД и ищет её
 268            // по РегистрационныйНомер. Подсказки должны возвращать те же значения,
 269            // иначе пользователь выбирает код из другого справочника, который затем
 270            // невозможно разрешить в Ref_Key при проведении.
 10271            var queryParams = new Dictionary<string, string> { ["$select"] = "РегистрационныйНомер" };
 10272            if (!string.IsNullOrEmpty(trimmed))
 2273                queryParams["$filter"] = $"startswith(РегистрационныйНомер,'{EscapeOData(trimmed)}')";
 274
 10275            var items = await _odataClient.GetSinglePageAsync<ODataGtdCode>(
 10276                "Catalog_НомераГТД",
 10277                top: limit,
 10278                queryParams: queryParams);
 279
 9280            return items
 3281                .Select(i => i.RegistrationNumber?.Trim())
 3282                .Where(code => !string.IsNullOrWhiteSpace(code))
 9283                .Distinct(StringComparer.OrdinalIgnoreCase)
 3284                .OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
 9285                .ToList()!;
 286        }
 1287        catch (Exception ex)
 288        {
 1289            _logger.LogWarning(ex, "[OData] Ошибка SearchGtdCodesAsync");
 1290            return new List<string>();
 291        }
 10292    }
 293
 294    public async Task<GetStockByArticleResponse> GetStockByArticlesAsync(IReadOnlyList<string> articles)
 295    {
 15296        var normalized = articles?
 18297            .Select(a => a?.Trim())
 18298            .Where(a => !string.IsNullOrWhiteSpace(a))
 16299            .Select(a => a!)
 15300            .Distinct(StringComparer.OrdinalIgnoreCase)
 15301            .ToList() ?? new List<string>();
 302
 15303        if (normalized.Count == 0)
 2304            return new GetStockByArticleResponse { Success = false, Error = "Не передан артикул" };
 305
 306        try
 307        {
 308            // Параллельно: GUID-lookup по всем артикулам + загрузка остатков + загрузка складов
 13309            var guidTasks = normalized
 14310                .Select(a => _referenceService.GetNomenclatureGuidByArticleAsync(a))
 13311                .ToArray();
 13312            var (balances, warehouses, guids) = (
 13313                await GetStockBalancesAsync(),
 13314                await _referenceService.GetWarehousesAsync(),
 13315                await Task.WhenAll(guidTasks)
 13316            );
 317
 41318            var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description);
 319
 13320            var items = normalized.Select((article, i) =>
 13321            {
 14322                var nomGuid = guids[i];
 14323                if (nomGuid == null)
 2324                    return new GetStockByArticleItem { Article = article, Success = false, Message = "Номенклатура не на
 13325
 12326                var stocks = balances
 15327                    .Where(kv => kv.Key.Item1 == nomGuid.Value && kv.Value > 0)
 12328                    .Select(kv => new OneCStockLine
 12329                    {
 12330                        Warehouse = warehouseNames.TryGetValue(kv.Key.Item2, out var wName)
 12331                            ? wName
 12332                            : kv.Key.Item2.ToString(),
 12333                        Quantity = kv.Value
 12334                    })
 12335                    .OrderBy(s => s.Warehouse)
 12336                    .ToList();
 13337
 12338                return new GetStockByArticleItem
 12339                {
 12340                    Article = article,
 12341                    Success = true,
 12342                    TotalQuantity = stocks.Sum(s => s.Quantity),
 12343                    Stocks = stocks
 12344                };
 13345            }).ToList();
 346
 13347            return new GetStockByArticleResponse { Success = true, Items = items };
 348        }
 0349        catch (Exception ex)
 350        {
 0351            return new GetStockByArticleResponse { Success = false, Error = ex.Message };
 352        }
 15353    }
 354
 355    private async Task<Dictionary<(Guid, Guid), decimal>> GetStockBalancesAsync()
 356    {
 357        // Быстрый путь: volatile-флаг видим всем потокам без захвата семафора
 14358        if (_stockCacheValid && _stockCache != null && DateTime.UtcNow < _stockCacheExpiry)
 1359            return _stockCache;
 360
 13361        await _stockSemaphore.WaitAsync();
 362        try
 363        {
 13364            if (_stockCacheValid && _stockCache != null && DateTime.UtcNow < _stockCacheExpiry)
 0365                return _stockCache;
 366
 13367            _stockCacheValid = false; // сбрасываем пока обновляем
 13368            var records = await _odataClient.GetListAsync<ODataStockRecord>("AccumulationRegister_ЗапасыНаСкладах");
 13369            var balances = new Dictionary<(Guid, Guid), decimal>();
 50370            foreach (var record in records)
 371            {
 58372                foreach (var movement in record.RecordSet ?? [])
 373                {
 17374                    var key = (movement.NomenclatureKey, movement.WarehouseKey);
 17375                    var delta = movement.RecordType == "Receipt" ? movement.Quantity : -movement.Quantity;
 17376                    balances.TryGetValue(key, out var existing);
 17377                    balances[key] = existing + delta;
 378                }
 379            }
 380
 13381            _stockCacheExpiry = DateTime.UtcNow.Add(StockCacheTtl);
 13382            _stockCache = balances;
 13383            _stockCacheValid = true; // публикуем после того как все поля записаны
 13384            return balances;
 385        }
 386        finally
 387        {
 13388            _stockSemaphore.Release();
 389        }
 14390    }
 391
 392    // Быстрый точечный запрос остатков по GUID номенклатуры через виртуальную таблицу Balance.
 393    // Не грузит весь регистр — только строки по одной номенклатуре.
 394    public async Task<List<OneCStockLine>> GetStockByArticleDirectAsync(string article)
 395    {
 1396        var nomGuid = await _referenceService.GetNomenclatureGuidByArticleAsync(article.Trim());
 1397        if (nomGuid == null)
 0398            return new List<OneCStockLine>();
 399
 400        try
 401        {
 1402            var balances = await _odataClient.GetListAsync<ODataStockBalance>(
 1403                OneCRegisterEndpoint.WithCondition(
 1404                    "AccumulationRegister_ЗапасыНаСкладах/Balance",
 1405                    $"Номенклатура_Key eq guid'{nomGuid}'"));
 406
 0407            if (balances.Count == 0)
 0408                return new List<OneCStockLine>();
 409
 0410            var warehouses = await _referenceService.GetWarehousesAsync();
 0411            var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description);
 412
 0413            return balances
 0414                .Where(b => b.QuantityBalance > 0)
 0415                .Select(b => new OneCStockLine
 0416                {
 0417                    Warehouse = warehouseNames.TryGetValue(b.WarehouseKey, out var name) ? name : b.WarehouseKey.ToStrin
 0418                    Quantity = b.QuantityBalance,
 0419                    Cost = b.CostBalance
 0420                })
 0421                .OrderBy(s => s.Warehouse)
 0422                .ToList();
 423        }
 424        catch (Exception ex)
 425        {
 426            // Точечный запрос не удался — падаем на полный регистр с кешем на 5 минут.
 1427            _logger.LogWarning(ex, "[OData] Balance condition unavailable for article {Article}; using cached register",
 428
 1429            var balances = await GetStockBalancesAsync();
 1430            var warehouses = await _referenceService.GetWarehousesAsync();
 3431            var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description);
 432
 1433            return balances
 1434                .Where(kv => kv.Key.Item1 == nomGuid.Value && kv.Value > 0)
 1435                .Select(kv => new OneCStockLine
 1436                {
 1437                    Warehouse = warehouseNames.TryGetValue(kv.Key.Item2, out var name)
 1438                        ? name
 1439                        : kv.Key.Item2.ToString(),
 1440                    Quantity = kv.Value
 1441                })
 1442                .OrderBy(s => s.Warehouse)
 1443                .ToList();
 444        }
 1445    }
 446
 447    public async Task<MovementMapResult?> GetMovementMapAsync(string article)
 448    {
 15449        var trimmed = article.Trim();
 15450        var nomGuid = await _referenceService.GetNomenclatureGuidByArticleAsync(trimmed);
 27451        if (nomGuid == null) return null;
 452
 1453        var filter = $"Номенклатура_Key eq guid'{nomGuid}'";
 454
 455        // Параллельно: баланс + строки документов + справочники.
 456        // Отбор баланса уходит в Condition (у виртуальной таблицы $filter запрещён).
 457        // Fresh не разрешает $filter по номенклатуре для строк резервирования,
 458        // поэтому загружаем весь небольшой набор и фильтруем после получения.
 1459        var (balanceTask, warehousesTask, reserveRowsTask) = (
 1460            _odataClient.GetListAsync<ODataStockBalance>(
 1461                OneCRegisterEndpoint.WithCondition(
 1462                    "AccumulationRegister_ЗапасыНаСкладах/Balance", filter)),
 1463            _referenceService.GetWarehousesAsync(),
 1464            _odataClient.GetListAsync<ODataReserveTabRow>("Document_РезервированиеЗапасов_Запасы")
 1465        );
 466
 1467        var receiptRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>(
 1468            "Document_ПриходнаяНакладная_Запасы", top: 500,
 1469            queryParams: new Dictionary<string, string> { ["$filter"] = filter, ["$select"] = "Ref_Key,Количество,Цена" 
 470
 1471        var expenseRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>(
 1472            "Document_РасходнаяНакладная_Запасы", top: 500,
 1473            queryParams: new Dictionary<string, string> { ["$filter"] = filter, ["$select"] = "Ref_Key,Количество,Цена" 
 474
 1475        var transferRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>(
 1476            "Document_ПеремещениеЗапасов_Запасы", top: 500,
 1477            queryParams: new Dictionary<string, string> { ["$filter"] = filter, ["$select"] = "Ref_Key,Количество" });
 478
 1479        await Task.WhenAll(balanceTask, warehousesTask, reserveRowsTask, receiptRowsTask, expenseRowsTask, transferRowsT
 480
 1481        var warehouses = await warehousesTask;
 3482        var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description);
 483
 484        // Получаем наименование номенклатуры
 1485        string nomName = trimmed;
 486        try
 487        {
 1488            var nomItems = await _odataClient.GetSinglePageAsync<ODataNomenclature>(
 1489                "Catalog_Номенклатура", top: 1,
 1490                queryParams: new Dictionary<string, string>
 1491                {
 1492                    ["$filter"] = $"Ref_Key eq guid'{nomGuid}'",
 1493                    ["$select"] = "Description"
 1494                });
 2495            if (nomItems.Count > 0) nomName = nomItems[0].Description ?? trimmed;
 1496        }
 0497        catch { /* имя некритично */ }
 498
 499        // Собираем уникальные Ref_Key → кол-во
 1500        var receiptRows = await receiptRowsTask;
 1501        var expenseRows = await expenseRowsTask;
 1502        var transferRows = await transferRowsTask;
 1503        var reserveRows = (await reserveRowsTask)
 0504            .Where(r => r.NomenclatureKey == nomGuid.Value && r.Quantity != 0)
 1505            .ToList();
 506
 9507        var receiptQtyMap = receiptRows.GroupBy(r => r.RefKey).ToDictionary(g => g.Key, g => (qty: g.Sum(r => r.Quantity
 5508        var expenseQtyMap = expenseRows.GroupBy(r => r.RefKey).ToDictionary(g => g.Key, g => (qty: g.Sum(r => r.Quantity
 1509        var transferQtyMap = transferRows.GroupBy(r => r.RefKey).ToDictionary(g => g.Key, g => (qty: g.Sum(r => r.Quanti
 510
 511        // Загружаем заголовки документов параллельно
 1512        var receiptKeys = receiptQtyMap.Keys.ToList();
 1513        var expenseKeys = expenseQtyMap.Keys.ToList();
 1514        var transferKeys = transferQtyMap.Keys.ToList();
 515
 516        async Task<Dictionary<Guid, T>> FetchHeaders<T>(string entity, List<Guid> keys) where T : ODataDocHeader
 517        {
 6518            if (keys.Count == 0) return new();
 519            // батч через OR — 1С поддерживает eq по Ref_Key
 2520            var batches = keys.Chunk(20).ToList();
 2521            var result = new Dictionary<Guid, T>();
 8522            foreach (var batch in batches)
 523            {
 5524                var orFilter = string.Join(" or ", batch.Select(k => $"Ref_Key eq guid'{k}'"));
 2525                var select = entity == "Document_РезервированиеЗапасов"
 2526                    ? "Ref_Key,DeletionMark,Posted"
 2527                    : typeof(T) == typeof(ODataTransferDocHeader)
 2528                        ? "Ref_Key,Date,Number,DeletionMark,Posted,СтруктурнаяЕдиница_Key,СтруктурнаяЕдиницаПолучатель_K
 2529                        : "Ref_Key,Date,Number,DeletionMark,Posted,СтруктурнаяЕдиница_Key";
 530                try
 531                {
 2532                    var rows = await _odataClient.GetSinglePageAsync<T>(entity, top: batch.Length + 5,
 2533                        queryParams: new Dictionary<string, string> { ["$filter"] = orFilter, ["$select"] = select });
 13534                    foreach (var r in rows) result[r.RefKey] = r;
 2535                }
 0536                catch (Exception ex)
 537                {
 0538                    _logger.LogWarning(ex, "[MovementMap] Ошибка загрузки заголовков {Entity}", entity);
 0539                }
 540            }
 2541            return result;
 4542        }
 543
 1544        var receiptHdrsTask = FetchHeaders<ODataDocHeader>("Document_ПриходнаяНакладная", receiptKeys);
 1545        var expenseHdrsTask = FetchHeaders<ODataExpenseDocHeader>("Document_РасходнаяНакладная", expenseKeys);
 1546        var transferHdrsTask = FetchHeaders<ODataTransferDocHeader>("Document_ПеремещениеЗапасов", transferKeys);
 1547        var reserveHdrsTask = FetchHeaders<ODataDocHeader>("Document_РезервированиеЗапасов", reserveRows.Select(r => r.R
 1548        await Task.WhenAll(receiptHdrsTask, expenseHdrsTask, transferHdrsTask, reserveHdrsTask);
 1549        var receiptHdrs = await receiptHdrsTask;
 1550        var expenseHdrs = await expenseHdrsTask;
 1551        var transferHdrs = await transferHdrsTask;
 1552        var reserveHdrs = await reserveHdrsTask;
 553
 554        // Строим список движений
 1555        var movements = new List<MovementItem>();
 556
 6557        foreach (var (key, (qty, price)) in receiptQtyMap)
 558        {
 2559            if (!receiptHdrs.TryGetValue(key, out var h)) continue;
 2560            if (string.IsNullOrEmpty(h.Date) || !DateTime.TryParse(h.Date, out var d)) continue;
 2561            movements.Add(new MovementItem(d, MovementKind.Receipt, "Приходная накладная", h.Number ?? string.Empty, key
 2562                qty, price, null, warehouseNames.GetValueOrDefault(h.WarehouseKey), h.DeletionMark, h.Posted));
 563        }
 564
 4565        foreach (var (key, (qty, price)) in expenseQtyMap)
 566        {
 1567            if (!expenseHdrs.TryGetValue(key, out var h)) continue;
 1568            if (string.IsNullOrEmpty(h.Date) || !DateTime.TryParse(h.Date, out var d)) continue;
 1569            movements.Add(new MovementItem(d, MovementKind.Expense, "Расходная накладная", h.Number ?? string.Empty, key
 1570                qty, price, warehouseNames.GetValueOrDefault(h.WarehouseKey), null, h.DeletionMark, h.Posted));
 571        }
 572
 2573        foreach (var (key, (qty, _)) in transferQtyMap)
 574        {
 0575            if (!transferHdrs.TryGetValue(key, out var h)) continue;
 0576            if (string.IsNullOrEmpty(h.Date) || !DateTime.TryParse(h.Date, out var d)) continue;
 0577            var th = (ODataTransferDocHeader)h;
 0578            movements.Add(new MovementItem(d, MovementKind.Transfer, "Перемещение запасов", h.Number ?? string.Empty, ke
 0579                qty, 0, warehouseNames.GetValueOrDefault(h.WarehouseKey), warehouseNames.GetValueOrDefault(th.WarehouseR
 0580                h.DeletionMark, h.Posted));
 581        }
 582
 4583        movements.Sort((a, b) => b.Date.CompareTo(a.Date));
 584
 1585        var balances = await balanceTask;
 1586        var currentBalance = balances
 1587            .Where(b => b.QuantityBalance > 0)
 1588            .Select(b => new OneCStockLine
 1589            {
 1590                Warehouse = warehouseNames.GetValueOrDefault(b.WarehouseKey, b.WarehouseKey.ToString()),
 1591                Quantity = b.QuantityBalance
 1592            })
 1593            .OrderBy(s => s.Warehouse)
 1594            .ToList();
 595
 1596        var currentReserve = reserveRows
 0597            .Where(r => reserveHdrs.TryGetValue(r.RefKey, out var header) && header.Posted && !header.DeletionMark)
 0598            .SelectMany(r => ReserveWarehouseDeltas(r, warehouseNames))
 0599            .GroupBy(r => r.Warehouse, StringComparer.OrdinalIgnoreCase)
 0600            .Select(g => new OneCStockLine { Warehouse = g.Key, Quantity = g.Sum(r => r.Quantity) })
 0601            .Where(r => r.Quantity != 0)
 0602            .OrderBy(r => r.Warehouse)
 1603            .ToList();
 604
 1605        var currentReserveLines = reserveRows
 0606            .Where(r => reserveHdrs.TryGetValue(r.RefKey, out var header) && header.Posted && !header.DeletionMark)
 0607            .SelectMany(r => ReserveWarehouseDeltas(r, warehouseNames)
 0608                .Select(delta => new OneCReserveLine(r.RefKey, r.LineNumber, delta.Warehouse ?? string.Empty, delta.Quan
 0609            .Where(r => !string.IsNullOrWhiteSpace(r.Warehouse) && r.Quantity > 0)
 0610            .OrderBy(r => r.Warehouse)
 0611            .ThenBy(r => r.DocumentRefKey)
 0612            .ThenBy(r => r.LineNumber)
 1613            .ToList();
 614
 4615        var active = movements.Where(m => !m.IsDeleted).ToList();
 1616        return new MovementMapResult
 1617        {
 1618            Article = trimmed,
 1619            NomenclatureName = nomName,
 1620            CurrentBalance = currentBalance,
 1621            CurrentReserve = currentReserve,
 1622            CurrentReserveLines = currentReserveLines,
 3623            TotalReceiptQty = (int)active.Where(m => m.Kind == MovementKind.Receipt).Sum(m => m.Quantity),
 2624            TotalReceiptDocs = active.Count(m => m.Kind == MovementKind.Receipt),
 3625            TotalExpenseQty = (int)active.Where(m => m.Kind == MovementKind.Expense).Sum(m => m.Quantity),
 2626            TotalExpenseDocs = active.Count(m => m.Kind == MovementKind.Expense),
 2627            TotalTransferQty = (int)active.Where(m => m.Kind == MovementKind.Transfer).Sum(m => m.Quantity),
 2628            TotalTransferDocs = active.Count(m => m.Kind == MovementKind.Transfer),
 1629            Movements = movements
 1630        };
 14631    }
 632
 633    /// <summary>
 634    /// Меняет одну строку проведённого документа резерва через отмену проведения → обновление → проведение.
 635    /// При сбое после отмены проведения пытается вернуть исходное содержимое документа.
 636    /// </summary>
 637    public async Task<(decimal OldQuantity, decimal NewQuantity)> UpdateReserveLineAsync(
 638        string article,
 639        Guid documentRefKey,
 640        int lineNumber,
 641        decimal newQuantity)
 642    {
 0643        if (lineNumber <= 0) throw new ArgumentOutOfRangeException(nameof(lineNumber));
 0644        if (newQuantity < 0) throw new ArgumentOutOfRangeException(nameof(newQuantity), "Количество не может быть отрица
 645
 0646        var nomenclatureKey = await _referenceService.GetNomenclatureGuidByArticleAsync(article.Trim());
 0647        if (nomenclatureKey == null) throw new InvalidOperationException("Номенклатура не найдена в 1С.");
 648
 649        const string endpoint = "Document_РезервированиеЗапасов";
 0650        var rawDocument = await _odataClient.GetRawJsonAsync(endpoint, documentRefKey);
 0651        var original = JObject.Parse(rawDocument);
 0652        if (original["Ref_Key"] == null)
 0653            throw new InvalidOperationException("Документ резерва не найден в 1С.");
 0654        if (original.Value<bool?>("DeletionMark") == true)
 0655            throw new InvalidOperationException("Документ резерва помечен на удаление.");
 656
 0657        var lines = original["Запасы"] as JArray
 0658                    ?? throw new InvalidOperationException("В документе резерва отсутствуют строки запасов.");
 0659        var line = lines.OfType<JObject>().FirstOrDefault(x => x.Value<int?>("LineNumber") == lineNumber)
 0660                   ?? throw new InvalidOperationException("Строка резерва не найдена.");
 0661        if (!Guid.TryParse(line.Value<string>("Номенклатура_Key"), out var lineNomenclatureKey)
 0662            || lineNomenclatureKey != nomenclatureKey.Value)
 0663            throw new InvalidOperationException("Строка резерва не относится к товару этого заказа.");
 664
 0665        var oldQuantity = line.Value<decimal?>("Количество") ?? 0m;
 0666        if (oldQuantity == newQuantity) return (oldQuantity, newQuantity);
 667
 0668        var wasPosted = original.Value<bool?>("Posted") == true;
 0669        var updateDocument = PrepareDocumentForUpdate((JObject)original.DeepClone());
 0670        var updateLine = ((JArray)updateDocument["Запасы"]!).OfType<JObject>()
 0671            .First(x => x.Value<int?>("LineNumber") == lineNumber);
 0672        updateLine["Количество"] = newQuantity;
 0673        updateDocument["Posted"] = false;
 674
 0675        var unposted = false;
 676        try
 677        {
 0678            if (wasPosted)
 679            {
 0680                await _odataClient.PatchAsync<ODataDocHeader>(endpoint, documentRefKey, new { Posted = false });
 0681                unposted = true;
 682            }
 683
 0684            await _odataClient.PutAsync(endpoint, documentRefKey, updateDocument);
 0685            if (wasPosted)
 0686                await _odataClient.PostDocumentAsync(endpoint, documentRefKey);
 0687        }
 0688        catch
 689        {
 0690            if (unposted)
 0691                await TryRestoreReserveDocumentAsync(endpoint, documentRefKey, original, wasPosted);
 0692            throw;
 693        }
 694
 0695        return (oldQuantity, newQuantity);
 0696    }
 697
 698    /// <summary>Корректирует фактический остаток только через новый документ инвентаризации 1С.</summary>
 699    public async Task<(decimal OldQuantity, decimal NewQuantity, string DocumentNumber)> AdjustStockByInventoryAsync(
 700        string article,
 701        string warehouseName,
 702        decimal newQuantity,
 703        string reason)
 704    {
 0705        if (newQuantity < 0) throw new ArgumentOutOfRangeException(nameof(newQuantity), "Остаток не может быть отрицател
 0706        var stock = await GetStockByArticleDirectAsync(article);
 0707        var oldQuantity = stock.FirstOrDefault(s => string.Equals(s.Warehouse, warehouseName, StringComparison.OrdinalIg
 0708        if (oldQuantity == newQuantity) return (oldQuantity, newQuantity, string.Empty);
 709
 0710        var nomenclatureKey = await _referenceService.GetNomenclatureGuidByArticleAsync(article.Trim())
 0711            ?? throw new InvalidOperationException("Номенклатура не найдена в 1С.");
 0712        var warehouseKey = await _referenceService.GetWarehouseGuidByNameAsync(warehouseName)
 0713            ?? throw new InvalidOperationException("Склад не найден в 1С.");
 0714        var organizationKey = await _referenceService.GetOrganizationGuidByInnAsync("0000000000")
 0715            ?? throw new InvalidOperationException("Организация Интерпартс не найдена в 1С.");
 0716        var template = await _odataClient.GetSinglePageAsync<ODataInventoryDocumentTemplate>(
 0717            "Document_ИнвентаризацияЗапасов", top: 1,
 0718            queryParams: new Dictionary<string, string> { ["$select"] = "ХозяйственнаяОперация_Key" });
 0719        var operationKey = template.FirstOrDefault()?.OperationKey ?? Guid.Empty;
 0720        if (operationKey == Guid.Empty) throw new InvalidOperationException("Не найдена хозяйственная операция для инвен
 721
 0722        var delta = newQuantity - oldQuantity;
 0723        var request = new ODataInventoryAdjustmentRequest
 0724        {
 0725            Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"),
 0726            OrganizationKey = organizationKey,
 0727            WarehouseKey = warehouseKey,
 0728            OperationKey = operationKey,
 0729            Comment = $"Корректировка остатка из Studio2. Причина: {reason.Trim()}",
 0730            Lines =
 0731            [
 0732                new ODataInventoryAdjustmentLine
 0733                {
 0734                    NomenclatureKey = nomenclatureKey,
 0735                    ActualQuantity = newQuantity,
 0736                    BookQuantity = oldQuantity,
 0737                    Difference = delta,
 0738                    Surplus = Math.Max(delta, 0m),
 0739                    Shortage = Math.Max(-delta, 0m)
 0740                }
 0741            ]
 0742        };
 743        const string endpoint = "Document_ИнвентаризацияЗапасов";
 0744        var document = await _odataClient.PostAsync<ODataInventoryAdjustmentRequest, ODataDocumentResponse>(endpoint, re
 0745        await _odataClient.PostDocumentAsync(endpoint, document.RefKey);
 0746        var posted = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(endpoint, document.RefKey);
 0747        return (oldQuantity, newQuantity, posted?.Number ?? document.Number ?? document.RefKey.ToString());
 0748    }
 749
 750    private async Task TryRestoreReserveDocumentAsync(string endpoint, Guid documentRefKey, JObject original, bool wasPo
 751    {
 752        try
 753        {
 0754            var restoreDocument = PrepareDocumentForUpdate((JObject)original.DeepClone());
 0755            restoreDocument["Posted"] = false;
 0756            await _odataClient.PutAsync(endpoint, documentRefKey, restoreDocument);
 0757            if (wasPosted)
 0758                await _odataClient.PostDocumentAsync(endpoint, documentRefKey);
 0759        }
 0760        catch (Exception restoreException)
 761        {
 0762            _logger.LogCritical(restoreException,
 0763                "[Reserve] Не удалось восстановить документ резерва {DocumentRefKey} после ошибки изменения.", documentR
 0764        }
 0765    }
 766
 767    private static JObject PrepareDocumentForUpdate(JObject document)
 768    {
 0769        document.Remove("Ref_Key");
 0770        document.Remove("DataVersion");
 0771        document.Remove("odata.metadata");
 0772        foreach (var property in document.Properties().Where(x => x.Name.EndsWith("@navigationLinkUrl", StringComparison
 0773            property.Remove();
 0774        return document;
 775    }
 776
 777    private static IEnumerable<OneCStockLine> ReserveWarehouseDeltas(
 778        ODataReserveTabRow row,
 779        IReadOnlyDictionary<Guid, string?> warehouseNames)
 780    {
 781        const string warehouseType = "StandardODATA.Catalog_СтруктурныеЕдиницы";
 782
 0783        if (row.SourceReservePlaceKey is Guid sourceKey
 0784            && string.Equals(row.SourceReservePlaceType, warehouseType, StringComparison.Ordinal)
 0785            && warehouseNames.TryGetValue(sourceKey, out var sourceName)
 0786            && !string.IsNullOrWhiteSpace(sourceName))
 787        {
 0788            yield return new OneCStockLine { Warehouse = sourceName, Quantity = row.Quantity };
 789        }
 790
 0791        if (row.TargetReservePlaceKey is Guid targetKey
 0792            && string.Equals(row.TargetReservePlaceType, warehouseType, StringComparison.Ordinal)
 0793            && warehouseNames.TryGetValue(targetKey, out var targetName)
 0794            && !string.IsNullOrWhiteSpace(targetName))
 795        {
 0796            yield return new OneCStockLine { Warehouse = targetName, Quantity = -row.Quantity };
 797        }
 0798    }
 799
 800    /// <summary>
 801    /// Для набора пар (Артикул, НазваниеСклада1С) возвращает остаток на конкретном складе
 802    /// и номер последнего проведённого приходного документа по этому артикулу.
 803    /// Пары с неизвестным артикулом или складом возвращают (0, null).
 804    /// </summary>
 805    public async Task<Dictionary<(string Article, string WarehouseName), (decimal Balance, string? LastReceiptNumber, Gu
 806        GetStockAndLastReceiptBatchAsync(IReadOnlyList<(string Article, string WarehouseName)> pairs)
 807    {
 0808        var result = new Dictionary<(string, string), (decimal, string?, Guid?, string?)>();
 0809        if (pairs.Count == 0) return result;
 810
 0811        var warehouses = await _referenceService.GetWarehousesAsync();
 0812        var warehouseGuidByName = warehouses
 0813            .Where(w => w.Description != null)
 0814            .ToDictionary(w => w.Description!, w => w.RefKey, StringComparer.OrdinalIgnoreCase);
 815
 0816        var uniqueArticles = pairs.Select(p => p.Article).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 817
 818        // Получаем GUIDs номенклатуры параллельно
 0819        var nomGuids = new ConcurrentDictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
 0820        await Parallel.ForEachAsync(uniqueArticles, new ParallelOptions { MaxDegreeOfParallelism = 5 },
 0821            async (article, _) =>
 0822            {
 0823                var guid = await _referenceService.GetNomenclatureGuidByArticleAsync(article);
 0824                if (guid != null) nomGuids[article] = guid.Value;
 0825            });
 826
 827        // Для каждого артикула: остатки по складам + последний приходный документ
 828        // Balance отбирается по номенклатуре через Condition, lastReceipt — последний по артикулу в целом
 0829        var articleData = new ConcurrentDictionary<string, (List<ODataStockBalance> Balances, string? LastReceiptNumber,
 0830            StringComparer.OrdinalIgnoreCase);
 831
 0832        await Parallel.ForEachAsync(uniqueArticles, new ParallelOptions { MaxDegreeOfParallelism = 3 },
 0833            async (article, _) =>
 0834            {
 0835                if (!nomGuids.TryGetValue(article, out var nomGuid)) return;
 0836                var nomFilter = $"Номенклатура_Key eq guid'{nomGuid}'";
 0837
 0838                var balancesTask = _odataClient.GetListAsync<ODataStockBalance>(
 0839                    OneCRegisterEndpoint.WithCondition(
 0840                        "AccumulationRegister_ЗапасыНаСкладах/Balance", nomFilter));
 0841
 0842                var tabRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>(
 0843                    "Document_ПриходнаяНакладная_Запасы", top: 500,
 0844                    queryParams: new Dictionary<string, string>
 0845                    {
 0846                        ["$filter"] = nomFilter,
 0847                        ["$select"] = "Ref_Key,Количество"
 0848                    });
 0849
 0850                await Task.WhenAll(balancesTask, tabRowsTask);
 0851
 0852                var docKeys = (await tabRowsTask).Select(r => r.RefKey).Distinct().ToList();
 0853                string? lastReceiptNumber = null;
 0854                Guid? lastReceiptRefKey = null;
 0855                DateTime lastDate = DateTime.MinValue;
 0856
 0857                foreach (var batch in docKeys.Chunk(20))
 0858                {
 0859                    try
 0860                    {
 0861                        var orFilter = string.Join(" or ", batch.Select(k => $"Ref_Key eq guid'{k}'"));
 0862                        var headers = await _odataClient.GetSinglePageAsync<ODataDocHeader>(
 0863                            "Document_ПриходнаяНакладная", top: batch.Length + 5,
 0864                            queryParams: new Dictionary<string, string>
 0865                            {
 0866                                ["$filter"] = orFilter,
 0867                                ["$select"] = "Ref_Key,Date,Number,DeletionMark,Posted,СтруктурнаяЕдиница_Key"
 0868                            });
 0869                        foreach (var h in headers)
 0870                        {
 0871                            if (h.DeletionMark || !h.Posted) continue;
 0872                            if (!DateTime.TryParse(h.Date, out var d)) continue;
 0873                            if (d > lastDate) { lastDate = d; lastReceiptNumber = h.Number; lastReceiptRefKey = h.RefKey
 0874                        }
 0875                    }
 0876                    catch (Exception ex)
 0877                    {
 0878                        _logger.LogWarning(ex, "[AssemblyList] Ошибка загрузки заголовков приходных для {Article}", arti
 0879                    }
 0880                }
 0881
 0882                articleData[article] = (await balancesTask, lastReceiptNumber, lastReceiptRefKey);
 0883            });
 884
 0885        foreach (var (article, warehouseName) in pairs)
 886        {
 0887            var key = (article, warehouseName);
 0888            if (result.ContainsKey(key)) continue;
 889
 0890            decimal balance = 0;
 0891            string? lastReceiptNumber = null;
 892
 0893            Guid? lastReceiptRefKey = null;
 0894            string? otherStock = null;
 0895            if (articleData.TryGetValue(article, out var data))
 896            {
 0897                Guid? whGuid = warehouseGuidByName.TryGetValue(warehouseName, out var g) ? g : null;
 0898                if (whGuid.HasValue)
 0899                    balance = data.Balances.FirstOrDefault(b => b.WarehouseKey == whGuid.Value)?.QuantityBalance ?? 0;
 0900                lastReceiptNumber = data.LastReceiptNumber;
 0901                lastReceiptRefKey = data.LastReceiptRefKey;
 902
 903                // Остатки на других складах (кроме целевого), только > 0
 0904                var guidToName = warehouseGuidByName.ToDictionary(kv => kv.Value, kv => kv.Key);
 0905                var others = data.Balances
 0906                    .Where(b => b.QuantityBalance > 0 && b.WarehouseKey != (whGuid ?? Guid.Empty))
 0907                    .Select(b => $"{guidToName.GetValueOrDefault(b.WarehouseKey, b.WarehouseKey.ToString())}: {b.Quantit
 0908                    .ToList();
 0909                if (others.Count > 0) otherStock = string.Join(", ", others);
 910            }
 911
 0912            result[key] = (balance, lastReceiptNumber, lastReceiptRefKey, otherStock);
 913        }
 914
 0915        return result;
 0916    }
 917
 2918    private static string EscapeOData(string value) => value.Replace("'", "''");
 919
 920    /// <summary>
 921    /// Находит все тестовые документы 1С (с [ТЕСТ] в комментарии) в приходных и перемещениях,
 922    /// снимает проведение. Возвращает список результатов.
 923    /// </summary>
 924    public async Task<List<string>> CancelTestPostingsAsync()
 925    {
 926        const string testMarker = "[ТЕСТ]";
 0927        var log = new List<string>();
 928
 0929        await CancelTestInEndpoint("Document_ПриходнаяНакладная", testMarker, log);
 0930        await CancelTestInEndpoint("Document_ПеремещениеЗапасов", testMarker, log);
 931
 0932        return log;
 0933    }
 934
 935    private async Task CancelTestInEndpoint(string endpoint, string marker, List<string> log)
 936    {
 937        try
 938        {
 0939            var docs = await _odataClient.GetListAsync<ODataDocumentWithComment>(
 0940                endpoint,
 0941                new Dictionary<string, string>
 0942                {
 0943                    ["$select"] = "Ref_Key,Number,Posted,Комментарий",
 0944                    ["$filter"] = "Posted eq true"
 0945                });
 946
 0947            var testDocs = docs.Where(d =>
 0948                d.Comment != null &&
 0949                d.Comment.Contains(marker, StringComparison.OrdinalIgnoreCase)).ToList();
 950
 0951            if (!testDocs.Any())
 952            {
 0953                log.Add($"{endpoint}: тестовых проведённых документов не найдено.");
 0954                return;
 955            }
 956
 0957            foreach (var doc in testDocs)
 958            {
 959                try
 960                {
 0961                    await _odataClient.PatchAsync<ODataDocumentWithComment>(endpoint, doc.RefKey, new { Posted = false }
 0962                    log.Add($"✅ {endpoint} {doc.Number} ({doc.RefKey}) — проведение снято.");
 0963                }
 0964                catch (Exception ex)
 965                {
 0966                    log.Add($"❌ {endpoint} {doc.Number} ({doc.RefKey}) — ошибка: {ex.Message}");
 0967                }
 0968            }
 0969        }
 0970        catch (Exception ex)
 971        {
 0972            log.Add($"❌ Ошибка загрузки {endpoint}: {ex.Message}");
 0973        }
 0974    }
 975}