| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using Newtonsoft.Json.Linq; |
| | | 4 | | using Services.ApiServices._1CApi.Models; |
| | | 5 | | using Services.ApiServices._1CApi.OData; |
| | | 6 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 7 | | |
| | | 8 | | namespace Services.ApiServices; |
| | | 9 | | |
| | | 10 | | /// <summary> |
| | | 11 | | /// Данные справочников 1С для UI (дропдауны складов, договоров, поставщиков, стран, ГТД). |
| | | 12 | | /// Все методы реализованы через 1С Fresh OData API. |
| | | 13 | | /// </summary> |
| | | 14 | | public 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; |
| | 1 | 22 | | private static DateTime _stockCacheExpiry = DateTime.MinValue; |
| | 1 | 23 | | private static readonly SemaphoreSlim _stockSemaphore = new(1, 1); |
| | 1 | 24 | | private static readonly TimeSpan StockCacheTtl = TimeSpan.FromMinutes(5); |
| | | 25 | | |
| | 56 | 26 | | public OneCDataManager( |
| | 56 | 27 | | IOneCODataReferenceService referenceService, |
| | 56 | 28 | | IOneCODataClient odataClient, |
| | 56 | 29 | | ILogger<OneCDataManager> logger) |
| | | 30 | | { |
| | 56 | 31 | | _referenceService = referenceService; |
| | 56 | 32 | | _odataClient = odataClient; |
| | 56 | 33 | | _logger = logger; |
| | 56 | 34 | | } |
| | | 35 | | |
| | | 36 | | /// <summary>Список складов для дропдауна. Link = Ref_Key (GUID).</summary> |
| | | 37 | | public async Task<List<WarehouseResponse>> GetWarehouseListAsync() |
| | | 38 | | { |
| | | 39 | | try |
| | | 40 | | { |
| | 0 | 41 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | 0 | 42 | | return warehouses |
| | 0 | 43 | | .Select(w => new WarehouseResponse |
| | 0 | 44 | | { |
| | 0 | 45 | | Name = w.Description, |
| | 0 | 46 | | Link = w.RefKey.ToString() |
| | 0 | 47 | | }) |
| | 0 | 48 | | .OrderBy(w => w.Name) |
| | 0 | 49 | | .ToList(); |
| | | 50 | | } |
| | 0 | 51 | | catch (Exception ex) |
| | | 52 | | { |
| | 0 | 53 | | _logger.LogWarning(ex, "[OData] Ошибка GetWarehouseListAsync"); |
| | 0 | 54 | | return new List<WarehouseResponse>(); |
| | | 55 | | } |
| | 0 | 56 | | } |
| | | 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 | | { |
| | 4 | 71 | | Guid? orgGuid = string.IsNullOrWhiteSpace(organizationInn) |
| | 4 | 72 | | ? null |
| | 4 | 73 | | : await _referenceService.GetOrganizationGuidByInnAsync(organizationInn); |
| | | 74 | | |
| | 4 | 75 | | if (!string.IsNullOrWhiteSpace(supplierInn)) |
| | | 76 | | { |
| | 3 | 77 | | var inn = supplierInn.Trim(); |
| | | 78 | | |
| | | 79 | | // Шаг 1: Catalog_Контрагенты → GUID по ИНН |
| | 3 | 80 | | var counterpartyGuid = await _referenceService.FindCounterpartyGuidByInnAsync(inn); |
| | 3 | 81 | | if (counterpartyGuid == null) |
| | 0 | 82 | | return new List<TreatyResponse>(); |
| | | 83 | | |
| | | 84 | | // Шаг 2: Catalog_ДоговорыКонтрагентов → договоры по Owner |
| | 3 | 85 | | var contracts = await _referenceService.GetContractsByCounterpartyAsync(counterpartyGuid.Value); |
| | 3 | 86 | | contracts = FilterByOrganization(contracts, orgGuid); |
| | 3 | 87 | | if (!contracts.Any()) |
| | 0 | 88 | | return new List<TreatyResponse>(); |
| | | 89 | | |
| | | 90 | | // Наименование контрагента для отображения (кеш → GET) |
| | 3 | 91 | | var counterparty = await _referenceService.GetCounterpartyByGuidAsync(counterpartyGuid.Value); |
| | 3 | 92 | | var partnerName = counterparty?.Description ?? string.Empty; |
| | | 93 | | |
| | 3 | 94 | | return contracts |
| | 11 | 95 | | .Select(c => new TreatyResponse |
| | 11 | 96 | | { |
| | 11 | 97 | | Link = c.RefKey.ToString(), |
| | 11 | 98 | | Name = c.Description, |
| | 11 | 99 | | Partner = partnerName, |
| | 11 | 100 | | Inn = inn |
| | 11 | 101 | | }) |
| | 11 | 102 | | .OrderBy(t => t.Name) |
| | 3 | 103 | | .ToList(); |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | // Fallback: все договоры без фильтра по ИНН |
| | 1 | 107 | | var allCounterparties = await _referenceService.GetCounterpartiesAsync(); |
| | | 108 | | // 1С может вернуть дублирующиеся Ref_Key — DistinctBy защищает ToDictionary от ArgumentException |
| | 1 | 109 | | var uniqueCounterparties = allCounterparties.DistinctBy(c => c.RefKey).ToList(); |
| | 1 | 110 | | var innByGuid = uniqueCounterparties.ToDictionary(c => c.RefKey, c => c.Inn); |
| | 1 | 111 | | var nameByGuid = uniqueCounterparties.ToDictionary(c => c.RefKey, c => c.Description); |
| | 1 | 112 | | var allContracts = FilterByOrganization(await _referenceService.GetAllContractsAsync(), orgGuid); |
| | | 113 | | |
| | 1 | 114 | | return allContracts |
| | 1 | 115 | | .Select(c => |
| | 1 | 116 | | { |
| | 3 | 117 | | innByGuid.TryGetValue(c.Owner, out var contractInn); |
| | 3 | 118 | | nameByGuid.TryGetValue(c.Owner, out var name); |
| | 3 | 119 | | return new TreatyResponse |
| | 3 | 120 | | { |
| | 3 | 121 | | Link = c.RefKey.ToString(), |
| | 3 | 122 | | Name = c.Description, |
| | 3 | 123 | | Partner = name ?? string.Empty, |
| | 3 | 124 | | Inn = contractInn ?? string.Empty |
| | 3 | 125 | | }; |
| | 1 | 126 | | }) |
| | 6 | 127 | | .OrderBy(t => t.Partner).ThenBy(t => t.Name) |
| | 1 | 128 | | .ToList(); |
| | | 129 | | } |
| | 0 | 130 | | catch (Exception ex) |
| | | 131 | | { |
| | 0 | 132 | | _logger.LogWarning(ex, "[OData] Ошибка GetTreatyListAsync(inn={Inn})", supplierInn); |
| | 0 | 133 | | return new List<TreatyResponse>(); |
| | | 134 | | } |
| | 4 | 135 | | } |
| | | 136 | | |
| | | 137 | | /// <summary>Договоры организации и договоры без привязки к организации; без организации (или неизвестной) — без фил |
| | | 138 | | private static List<ODataContract> FilterByOrganization(List<ODataContract> contracts, Guid? orgGuid) |
| | 4 | 139 | | => orgGuid == null |
| | 4 | 140 | | ? contracts |
| | 12 | 141 | | : 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 | | { |
| | 1 | 148 | | var organizations = await _referenceService.GetOrganizationsAsync(); |
| | 1 | 149 | | return organizations |
| | 4 | 150 | | .Where(o => !o.DeletionMark && !string.IsNullOrWhiteSpace(o.Inn)) |
| | 2 | 151 | | .Select(o => new OrganizationResponse { Link = o.RefKey.ToString(), Name = o.Description, Inn = o.Inn!.T |
| | 2 | 152 | | .OrderBy(o => o.Name) |
| | 1 | 153 | | .ToList(); |
| | | 154 | | } |
| | 0 | 155 | | catch (Exception ex) |
| | | 156 | | { |
| | 0 | 157 | | _logger.LogWarning(ex, "[OData] Ошибка GetOrganizationListAsync"); |
| | 0 | 158 | | return new List<OrganizationResponse>(); |
| | | 159 | | } |
| | 1 | 160 | | } |
| | | 161 | | |
| | | 162 | | /// <summary>Список поставщиков 1С. Link = Ref_Key (GUID), Unp = ИНН или регистрационный номер.</summary> |
| | | 163 | | public async Task<List<SupplierResponse>> GetSupplierListAsync() |
| | | 164 | | { |
| | | 165 | | try |
| | | 166 | | { |
| | 0 | 167 | | var counterparties = await _referenceService.GetCounterpartiesAsync(); |
| | 0 | 168 | | return counterparties |
| | 0 | 169 | | .Select(c => new SupplierResponse |
| | 0 | 170 | | { |
| | 0 | 171 | | Name = c.Description, |
| | 0 | 172 | | FullName = c.Description, |
| | 0 | 173 | | Unp = c.Identifier, |
| | 0 | 174 | | Link = c.RefKey.ToString() |
| | 0 | 175 | | }) |
| | 0 | 176 | | .OrderBy(s => s.Name) |
| | 0 | 177 | | .ToList(); |
| | | 178 | | } |
| | 0 | 179 | | catch (Exception ex) |
| | | 180 | | { |
| | 0 | 181 | | _logger.LogWarning(ex, "[OData] Ошибка GetSupplierListAsync"); |
| | 0 | 182 | | return new List<SupplierResponse>(); |
| | | 183 | | } |
| | 0 | 184 | | } |
| | | 185 | | |
| | | 186 | | /// <summary> |
| | | 187 | | /// Поиск контрагентов по ИНН или названию. Используется для live-автодополнения в UI. |
| | | 188 | | /// </summary> |
| | | 189 | | public async Task<List<ODataCounterparty>> SearchCounterpartiesAsync(string? query, int limit = 15) |
| | | 190 | | { |
| | 3 | 191 | | var all = await _referenceService.GetCounterpartiesAsync(); |
| | 3 | 192 | | if (string.IsNullOrWhiteSpace(query)) |
| | 0 | 193 | | return all.Take(limit).ToList(); |
| | 3 | 194 | | var q = query.Trim().ToLowerInvariant(); |
| | 3 | 195 | | var results = all |
| | 3 | 196 | | .Where(c => |
| | 0 | 197 | | (c.Inn?.ToLowerInvariant().Contains(q) == true) || |
| | 0 | 198 | | (c.RegistrationNumber?.ToLowerInvariant().Contains(q) == true) || |
| | 0 | 199 | | (c.Description?.ToLowerInvariant().Contains(q) == true)) |
| | 3 | 200 | | .Take(limit) |
| | 3 | 201 | | .ToList(); |
| | | 202 | | |
| | 3 | 203 | | if (q.All(char.IsDigit) && |
| | 3 | 204 | | !results.Any(c => string.Equals(c.Identifier?.Trim(), query.Trim(), StringComparison.OrdinalIgnoreCase))) |
| | | 205 | | { |
| | 2 | 206 | | var counterpartyGuid = await _referenceService.FindCounterpartyGuidByInnAsync(query.Trim()); |
| | 2 | 207 | | if (counterpartyGuid.HasValue) |
| | | 208 | | { |
| | 2 | 209 | | var counterparty = await _referenceService.GetCounterpartyByGuidAsync(counterpartyGuid.Value); |
| | 2 | 210 | | if (counterparty != null && |
| | 2 | 211 | | string.Equals(counterparty.Identifier?.Trim(), query.Trim(), StringComparison.OrdinalIgnoreCase) && |
| | 2 | 212 | | results.All(c => c.RefKey != counterparty.RefKey)) |
| | | 213 | | { |
| | 2 | 214 | | results.Insert(0, counterparty); |
| | 2 | 215 | | if (results.Count > limit) |
| | 0 | 216 | | results.RemoveAt(results.Count - 1); |
| | | 217 | | } |
| | 2 | 218 | | } |
| | | 219 | | } |
| | | 220 | | |
| | 3 | 221 | | return results; |
| | 3 | 222 | | } |
| | | 223 | | |
| | | 224 | | public async Task<List<ODataOrganization>> SearchOrganizationsAsync(string? query, int limit = 15) |
| | | 225 | | { |
| | 0 | 226 | | var all = await _referenceService.GetOrganizationsAsync(); |
| | 0 | 227 | | if (string.IsNullOrWhiteSpace(query)) |
| | 0 | 228 | | return all.Where(o => !string.IsNullOrWhiteSpace(o.Inn)).Take(limit).ToList(); |
| | 0 | 229 | | var q = query.Trim().ToLowerInvariant(); |
| | 0 | 230 | | return all |
| | 0 | 231 | | .Where(o => |
| | 0 | 232 | | (o.Inn?.ToLowerInvariant().Contains(q) == true) || |
| | 0 | 233 | | (o.Description?.ToLowerInvariant().Contains(q) == true)) |
| | 0 | 234 | | .Take(limit) |
| | 0 | 235 | | .ToList(); |
| | 0 | 236 | | } |
| | | 237 | | |
| | | 238 | | public async Task<List<string>> GetCountryListAsync() |
| | | 239 | | { |
| | | 240 | | try |
| | | 241 | | { |
| | 5 | 242 | | var countries = await _odataClient.GetListAsync<ODataCountry>( |
| | 5 | 243 | | "Catalog_СтраныМира", |
| | 5 | 244 | | new Dictionary<string, string> { ["$select"] = "Ref_Key,Description" }); |
| | 4 | 245 | | return countries |
| | 10 | 246 | | .Select(c => c.Description?.Trim()) |
| | 10 | 247 | | .Where(name => !string.IsNullOrWhiteSpace(name)) |
| | 4 | 248 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 5 | 249 | | .OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase) |
| | 4 | 250 | | .ToList()!; |
| | | 251 | | } |
| | 1 | 252 | | catch (Exception ex) |
| | | 253 | | { |
| | 1 | 254 | | _logger.LogWarning(ex, "[OData] Ошибка GetCountryListAsync"); |
| | 1 | 255 | | return new List<string>(); |
| | | 256 | | } |
| | 5 | 257 | | } |
| | | 258 | | |
| | | 259 | | public async Task<List<string>> SearchGtdCodesAsync(string? query, int limit = 20) |
| | | 260 | | { |
| | 13 | 261 | | if (limit <= 0) limit = 20; |
| | 11 | 262 | | if (limit > 100) limit = 100; |
| | 10 | 263 | | var trimmed = (query ?? string.Empty).Trim(); |
| | | 264 | | |
| | | 265 | | try |
| | | 266 | | { |
| | | 267 | | // Приходная накладная сохраняет ссылку на Catalog_НомераГТД и ищет её |
| | | 268 | | // по РегистрационныйНомер. Подсказки должны возвращать те же значения, |
| | | 269 | | // иначе пользователь выбирает код из другого справочника, который затем |
| | | 270 | | // невозможно разрешить в Ref_Key при проведении. |
| | 10 | 271 | | var queryParams = new Dictionary<string, string> { ["$select"] = "РегистрационныйНомер" }; |
| | 10 | 272 | | if (!string.IsNullOrEmpty(trimmed)) |
| | 2 | 273 | | queryParams["$filter"] = $"startswith(РегистрационныйНомер,'{EscapeOData(trimmed)}')"; |
| | | 274 | | |
| | 10 | 275 | | var items = await _odataClient.GetSinglePageAsync<ODataGtdCode>( |
| | 10 | 276 | | "Catalog_НомераГТД", |
| | 10 | 277 | | top: limit, |
| | 10 | 278 | | queryParams: queryParams); |
| | | 279 | | |
| | 9 | 280 | | return items |
| | 3 | 281 | | .Select(i => i.RegistrationNumber?.Trim()) |
| | 3 | 282 | | .Where(code => !string.IsNullOrWhiteSpace(code)) |
| | 9 | 283 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 3 | 284 | | .OrderBy(code => code, StringComparer.OrdinalIgnoreCase) |
| | 9 | 285 | | .ToList()!; |
| | | 286 | | } |
| | 1 | 287 | | catch (Exception ex) |
| | | 288 | | { |
| | 1 | 289 | | _logger.LogWarning(ex, "[OData] Ошибка SearchGtdCodesAsync"); |
| | 1 | 290 | | return new List<string>(); |
| | | 291 | | } |
| | 10 | 292 | | } |
| | | 293 | | |
| | | 294 | | public async Task<GetStockByArticleResponse> GetStockByArticlesAsync(IReadOnlyList<string> articles) |
| | | 295 | | { |
| | 15 | 296 | | var normalized = articles? |
| | 18 | 297 | | .Select(a => a?.Trim()) |
| | 18 | 298 | | .Where(a => !string.IsNullOrWhiteSpace(a)) |
| | 16 | 299 | | .Select(a => a!) |
| | 15 | 300 | | .Distinct(StringComparer.OrdinalIgnoreCase) |
| | 15 | 301 | | .ToList() ?? new List<string>(); |
| | | 302 | | |
| | 15 | 303 | | if (normalized.Count == 0) |
| | 2 | 304 | | return new GetStockByArticleResponse { Success = false, Error = "Не передан артикул" }; |
| | | 305 | | |
| | | 306 | | try |
| | | 307 | | { |
| | | 308 | | // Параллельно: GUID-lookup по всем артикулам + загрузка остатков + загрузка складов |
| | 13 | 309 | | var guidTasks = normalized |
| | 14 | 310 | | .Select(a => _referenceService.GetNomenclatureGuidByArticleAsync(a)) |
| | 13 | 311 | | .ToArray(); |
| | 13 | 312 | | var (balances, warehouses, guids) = ( |
| | 13 | 313 | | await GetStockBalancesAsync(), |
| | 13 | 314 | | await _referenceService.GetWarehousesAsync(), |
| | 13 | 315 | | await Task.WhenAll(guidTasks) |
| | 13 | 316 | | ); |
| | | 317 | | |
| | 41 | 318 | | var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description); |
| | | 319 | | |
| | 13 | 320 | | var items = normalized.Select((article, i) => |
| | 13 | 321 | | { |
| | 14 | 322 | | var nomGuid = guids[i]; |
| | 14 | 323 | | if (nomGuid == null) |
| | 2 | 324 | | return new GetStockByArticleItem { Article = article, Success = false, Message = "Номенклатура не на |
| | 13 | 325 | | |
| | 12 | 326 | | var stocks = balances |
| | 15 | 327 | | .Where(kv => kv.Key.Item1 == nomGuid.Value && kv.Value > 0) |
| | 12 | 328 | | .Select(kv => new OneCStockLine |
| | 12 | 329 | | { |
| | 12 | 330 | | Warehouse = warehouseNames.TryGetValue(kv.Key.Item2, out var wName) |
| | 12 | 331 | | ? wName |
| | 12 | 332 | | : kv.Key.Item2.ToString(), |
| | 12 | 333 | | Quantity = kv.Value |
| | 12 | 334 | | }) |
| | 12 | 335 | | .OrderBy(s => s.Warehouse) |
| | 12 | 336 | | .ToList(); |
| | 13 | 337 | | |
| | 12 | 338 | | return new GetStockByArticleItem |
| | 12 | 339 | | { |
| | 12 | 340 | | Article = article, |
| | 12 | 341 | | Success = true, |
| | 12 | 342 | | TotalQuantity = stocks.Sum(s => s.Quantity), |
| | 12 | 343 | | Stocks = stocks |
| | 12 | 344 | | }; |
| | 13 | 345 | | }).ToList(); |
| | | 346 | | |
| | 13 | 347 | | return new GetStockByArticleResponse { Success = true, Items = items }; |
| | | 348 | | } |
| | 0 | 349 | | catch (Exception ex) |
| | | 350 | | { |
| | 0 | 351 | | return new GetStockByArticleResponse { Success = false, Error = ex.Message }; |
| | | 352 | | } |
| | 15 | 353 | | } |
| | | 354 | | |
| | | 355 | | private async Task<Dictionary<(Guid, Guid), decimal>> GetStockBalancesAsync() |
| | | 356 | | { |
| | | 357 | | // Быстрый путь: volatile-флаг видим всем потокам без захвата семафора |
| | 14 | 358 | | if (_stockCacheValid && _stockCache != null && DateTime.UtcNow < _stockCacheExpiry) |
| | 1 | 359 | | return _stockCache; |
| | | 360 | | |
| | 13 | 361 | | await _stockSemaphore.WaitAsync(); |
| | | 362 | | try |
| | | 363 | | { |
| | 13 | 364 | | if (_stockCacheValid && _stockCache != null && DateTime.UtcNow < _stockCacheExpiry) |
| | 0 | 365 | | return _stockCache; |
| | | 366 | | |
| | 13 | 367 | | _stockCacheValid = false; // сбрасываем пока обновляем |
| | 13 | 368 | | var records = await _odataClient.GetListAsync<ODataStockRecord>("AccumulationRegister_ЗапасыНаСкладах"); |
| | 13 | 369 | | var balances = new Dictionary<(Guid, Guid), decimal>(); |
| | 50 | 370 | | foreach (var record in records) |
| | | 371 | | { |
| | 58 | 372 | | foreach (var movement in record.RecordSet ?? []) |
| | | 373 | | { |
| | 17 | 374 | | var key = (movement.NomenclatureKey, movement.WarehouseKey); |
| | 17 | 375 | | var delta = movement.RecordType == "Receipt" ? movement.Quantity : -movement.Quantity; |
| | 17 | 376 | | balances.TryGetValue(key, out var existing); |
| | 17 | 377 | | balances[key] = existing + delta; |
| | | 378 | | } |
| | | 379 | | } |
| | | 380 | | |
| | 13 | 381 | | _stockCacheExpiry = DateTime.UtcNow.Add(StockCacheTtl); |
| | 13 | 382 | | _stockCache = balances; |
| | 13 | 383 | | _stockCacheValid = true; // публикуем после того как все поля записаны |
| | 13 | 384 | | return balances; |
| | | 385 | | } |
| | | 386 | | finally |
| | | 387 | | { |
| | 13 | 388 | | _stockSemaphore.Release(); |
| | | 389 | | } |
| | 14 | 390 | | } |
| | | 391 | | |
| | | 392 | | // Быстрый точечный запрос остатков по GUID номенклатуры через виртуальную таблицу Balance. |
| | | 393 | | // Не грузит весь регистр — только строки по одной номенклатуре. |
| | | 394 | | public async Task<List<OneCStockLine>> GetStockByArticleDirectAsync(string article) |
| | | 395 | | { |
| | 1 | 396 | | var nomGuid = await _referenceService.GetNomenclatureGuidByArticleAsync(article.Trim()); |
| | 1 | 397 | | if (nomGuid == null) |
| | 0 | 398 | | return new List<OneCStockLine>(); |
| | | 399 | | |
| | | 400 | | try |
| | | 401 | | { |
| | 1 | 402 | | var balances = await _odataClient.GetListAsync<ODataStockBalance>( |
| | 1 | 403 | | OneCRegisterEndpoint.WithCondition( |
| | 1 | 404 | | "AccumulationRegister_ЗапасыНаСкладах/Balance", |
| | 1 | 405 | | $"Номенклатура_Key eq guid'{nomGuid}'")); |
| | | 406 | | |
| | 0 | 407 | | if (balances.Count == 0) |
| | 0 | 408 | | return new List<OneCStockLine>(); |
| | | 409 | | |
| | 0 | 410 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | 0 | 411 | | var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description); |
| | | 412 | | |
| | 0 | 413 | | return balances |
| | 0 | 414 | | .Where(b => b.QuantityBalance > 0) |
| | 0 | 415 | | .Select(b => new OneCStockLine |
| | 0 | 416 | | { |
| | 0 | 417 | | Warehouse = warehouseNames.TryGetValue(b.WarehouseKey, out var name) ? name : b.WarehouseKey.ToStrin |
| | 0 | 418 | | Quantity = b.QuantityBalance, |
| | 0 | 419 | | Cost = b.CostBalance |
| | 0 | 420 | | }) |
| | 0 | 421 | | .OrderBy(s => s.Warehouse) |
| | 0 | 422 | | .ToList(); |
| | | 423 | | } |
| | | 424 | | catch (Exception ex) |
| | | 425 | | { |
| | | 426 | | // Точечный запрос не удался — падаем на полный регистр с кешем на 5 минут. |
| | 1 | 427 | | _logger.LogWarning(ex, "[OData] Balance condition unavailable for article {Article}; using cached register", |
| | | 428 | | |
| | 1 | 429 | | var balances = await GetStockBalancesAsync(); |
| | 1 | 430 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | 3 | 431 | | var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description); |
| | | 432 | | |
| | 1 | 433 | | return balances |
| | 1 | 434 | | .Where(kv => kv.Key.Item1 == nomGuid.Value && kv.Value > 0) |
| | 1 | 435 | | .Select(kv => new OneCStockLine |
| | 1 | 436 | | { |
| | 1 | 437 | | Warehouse = warehouseNames.TryGetValue(kv.Key.Item2, out var name) |
| | 1 | 438 | | ? name |
| | 1 | 439 | | : kv.Key.Item2.ToString(), |
| | 1 | 440 | | Quantity = kv.Value |
| | 1 | 441 | | }) |
| | 1 | 442 | | .OrderBy(s => s.Warehouse) |
| | 1 | 443 | | .ToList(); |
| | | 444 | | } |
| | 1 | 445 | | } |
| | | 446 | | |
| | | 447 | | public async Task<MovementMapResult?> GetMovementMapAsync(string article) |
| | | 448 | | { |
| | 15 | 449 | | var trimmed = article.Trim(); |
| | 15 | 450 | | var nomGuid = await _referenceService.GetNomenclatureGuidByArticleAsync(trimmed); |
| | 27 | 451 | | if (nomGuid == null) return null; |
| | | 452 | | |
| | 1 | 453 | | var filter = $"Номенклатура_Key eq guid'{nomGuid}'"; |
| | | 454 | | |
| | | 455 | | // Параллельно: баланс + строки документов + справочники. |
| | | 456 | | // Отбор баланса уходит в Condition (у виртуальной таблицы $filter запрещён). |
| | | 457 | | // Fresh не разрешает $filter по номенклатуре для строк резервирования, |
| | | 458 | | // поэтому загружаем весь небольшой набор и фильтруем после получения. |
| | 1 | 459 | | var (balanceTask, warehousesTask, reserveRowsTask) = ( |
| | 1 | 460 | | _odataClient.GetListAsync<ODataStockBalance>( |
| | 1 | 461 | | OneCRegisterEndpoint.WithCondition( |
| | 1 | 462 | | "AccumulationRegister_ЗапасыНаСкладах/Balance", filter)), |
| | 1 | 463 | | _referenceService.GetWarehousesAsync(), |
| | 1 | 464 | | _odataClient.GetListAsync<ODataReserveTabRow>("Document_РезервированиеЗапасов_Запасы") |
| | 1 | 465 | | ); |
| | | 466 | | |
| | 1 | 467 | | var receiptRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>( |
| | 1 | 468 | | "Document_ПриходнаяНакладная_Запасы", top: 500, |
| | 1 | 469 | | queryParams: new Dictionary<string, string> { ["$filter"] = filter, ["$select"] = "Ref_Key,Количество,Цена" |
| | | 470 | | |
| | 1 | 471 | | var expenseRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>( |
| | 1 | 472 | | "Document_РасходнаяНакладная_Запасы", top: 500, |
| | 1 | 473 | | queryParams: new Dictionary<string, string> { ["$filter"] = filter, ["$select"] = "Ref_Key,Количество,Цена" |
| | | 474 | | |
| | 1 | 475 | | var transferRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>( |
| | 1 | 476 | | "Document_ПеремещениеЗапасов_Запасы", top: 500, |
| | 1 | 477 | | queryParams: new Dictionary<string, string> { ["$filter"] = filter, ["$select"] = "Ref_Key,Количество" }); |
| | | 478 | | |
| | 1 | 479 | | await Task.WhenAll(balanceTask, warehousesTask, reserveRowsTask, receiptRowsTask, expenseRowsTask, transferRowsT |
| | | 480 | | |
| | 1 | 481 | | var warehouses = await warehousesTask; |
| | 3 | 482 | | var warehouseNames = warehouses.ToDictionary(w => w.RefKey, w => w.Description); |
| | | 483 | | |
| | | 484 | | // Получаем наименование номенклатуры |
| | 1 | 485 | | string nomName = trimmed; |
| | | 486 | | try |
| | | 487 | | { |
| | 1 | 488 | | var nomItems = await _odataClient.GetSinglePageAsync<ODataNomenclature>( |
| | 1 | 489 | | "Catalog_Номенклатура", top: 1, |
| | 1 | 490 | | queryParams: new Dictionary<string, string> |
| | 1 | 491 | | { |
| | 1 | 492 | | ["$filter"] = $"Ref_Key eq guid'{nomGuid}'", |
| | 1 | 493 | | ["$select"] = "Description" |
| | 1 | 494 | | }); |
| | 2 | 495 | | if (nomItems.Count > 0) nomName = nomItems[0].Description ?? trimmed; |
| | 1 | 496 | | } |
| | 0 | 497 | | catch { /* имя некритично */ } |
| | | 498 | | |
| | | 499 | | // Собираем уникальные Ref_Key → кол-во |
| | 1 | 500 | | var receiptRows = await receiptRowsTask; |
| | 1 | 501 | | var expenseRows = await expenseRowsTask; |
| | 1 | 502 | | var transferRows = await transferRowsTask; |
| | 1 | 503 | | var reserveRows = (await reserveRowsTask) |
| | 0 | 504 | | .Where(r => r.NomenclatureKey == nomGuid.Value && r.Quantity != 0) |
| | 1 | 505 | | .ToList(); |
| | | 506 | | |
| | 9 | 507 | | var receiptQtyMap = receiptRows.GroupBy(r => r.RefKey).ToDictionary(g => g.Key, g => (qty: g.Sum(r => r.Quantity |
| | 5 | 508 | | var expenseQtyMap = expenseRows.GroupBy(r => r.RefKey).ToDictionary(g => g.Key, g => (qty: g.Sum(r => r.Quantity |
| | 1 | 509 | | var transferQtyMap = transferRows.GroupBy(r => r.RefKey).ToDictionary(g => g.Key, g => (qty: g.Sum(r => r.Quanti |
| | | 510 | | |
| | | 511 | | // Загружаем заголовки документов параллельно |
| | 1 | 512 | | var receiptKeys = receiptQtyMap.Keys.ToList(); |
| | 1 | 513 | | var expenseKeys = expenseQtyMap.Keys.ToList(); |
| | 1 | 514 | | var transferKeys = transferQtyMap.Keys.ToList(); |
| | | 515 | | |
| | | 516 | | async Task<Dictionary<Guid, T>> FetchHeaders<T>(string entity, List<Guid> keys) where T : ODataDocHeader |
| | | 517 | | { |
| | 6 | 518 | | if (keys.Count == 0) return new(); |
| | | 519 | | // батч через OR — 1С поддерживает eq по Ref_Key |
| | 2 | 520 | | var batches = keys.Chunk(20).ToList(); |
| | 2 | 521 | | var result = new Dictionary<Guid, T>(); |
| | 8 | 522 | | foreach (var batch in batches) |
| | | 523 | | { |
| | 5 | 524 | | var orFilter = string.Join(" or ", batch.Select(k => $"Ref_Key eq guid'{k}'")); |
| | 2 | 525 | | var select = entity == "Document_РезервированиеЗапасов" |
| | 2 | 526 | | ? "Ref_Key,DeletionMark,Posted" |
| | 2 | 527 | | : typeof(T) == typeof(ODataTransferDocHeader) |
| | 2 | 528 | | ? "Ref_Key,Date,Number,DeletionMark,Posted,СтруктурнаяЕдиница_Key,СтруктурнаяЕдиницаПолучатель_K |
| | 2 | 529 | | : "Ref_Key,Date,Number,DeletionMark,Posted,СтруктурнаяЕдиница_Key"; |
| | | 530 | | try |
| | | 531 | | { |
| | 2 | 532 | | var rows = await _odataClient.GetSinglePageAsync<T>(entity, top: batch.Length + 5, |
| | 2 | 533 | | queryParams: new Dictionary<string, string> { ["$filter"] = orFilter, ["$select"] = select }); |
| | 13 | 534 | | foreach (var r in rows) result[r.RefKey] = r; |
| | 2 | 535 | | } |
| | 0 | 536 | | catch (Exception ex) |
| | | 537 | | { |
| | 0 | 538 | | _logger.LogWarning(ex, "[MovementMap] Ошибка загрузки заголовков {Entity}", entity); |
| | 0 | 539 | | } |
| | | 540 | | } |
| | 2 | 541 | | return result; |
| | 4 | 542 | | } |
| | | 543 | | |
| | 1 | 544 | | var receiptHdrsTask = FetchHeaders<ODataDocHeader>("Document_ПриходнаяНакладная", receiptKeys); |
| | 1 | 545 | | var expenseHdrsTask = FetchHeaders<ODataExpenseDocHeader>("Document_РасходнаяНакладная", expenseKeys); |
| | 1 | 546 | | var transferHdrsTask = FetchHeaders<ODataTransferDocHeader>("Document_ПеремещениеЗапасов", transferKeys); |
| | 1 | 547 | | var reserveHdrsTask = FetchHeaders<ODataDocHeader>("Document_РезервированиеЗапасов", reserveRows.Select(r => r.R |
| | 1 | 548 | | await Task.WhenAll(receiptHdrsTask, expenseHdrsTask, transferHdrsTask, reserveHdrsTask); |
| | 1 | 549 | | var receiptHdrs = await receiptHdrsTask; |
| | 1 | 550 | | var expenseHdrs = await expenseHdrsTask; |
| | 1 | 551 | | var transferHdrs = await transferHdrsTask; |
| | 1 | 552 | | var reserveHdrs = await reserveHdrsTask; |
| | | 553 | | |
| | | 554 | | // Строим список движений |
| | 1 | 555 | | var movements = new List<MovementItem>(); |
| | | 556 | | |
| | 6 | 557 | | foreach (var (key, (qty, price)) in receiptQtyMap) |
| | | 558 | | { |
| | 2 | 559 | | if (!receiptHdrs.TryGetValue(key, out var h)) continue; |
| | 2 | 560 | | if (string.IsNullOrEmpty(h.Date) || !DateTime.TryParse(h.Date, out var d)) continue; |
| | 2 | 561 | | movements.Add(new MovementItem(d, MovementKind.Receipt, "Приходная накладная", h.Number ?? string.Empty, key |
| | 2 | 562 | | qty, price, null, warehouseNames.GetValueOrDefault(h.WarehouseKey), h.DeletionMark, h.Posted)); |
| | | 563 | | } |
| | | 564 | | |
| | 4 | 565 | | foreach (var (key, (qty, price)) in expenseQtyMap) |
| | | 566 | | { |
| | 1 | 567 | | if (!expenseHdrs.TryGetValue(key, out var h)) continue; |
| | 1 | 568 | | if (string.IsNullOrEmpty(h.Date) || !DateTime.TryParse(h.Date, out var d)) continue; |
| | 1 | 569 | | movements.Add(new MovementItem(d, MovementKind.Expense, "Расходная накладная", h.Number ?? string.Empty, key |
| | 1 | 570 | | qty, price, warehouseNames.GetValueOrDefault(h.WarehouseKey), null, h.DeletionMark, h.Posted)); |
| | | 571 | | } |
| | | 572 | | |
| | 2 | 573 | | foreach (var (key, (qty, _)) in transferQtyMap) |
| | | 574 | | { |
| | 0 | 575 | | if (!transferHdrs.TryGetValue(key, out var h)) continue; |
| | 0 | 576 | | if (string.IsNullOrEmpty(h.Date) || !DateTime.TryParse(h.Date, out var d)) continue; |
| | 0 | 577 | | var th = (ODataTransferDocHeader)h; |
| | 0 | 578 | | movements.Add(new MovementItem(d, MovementKind.Transfer, "Перемещение запасов", h.Number ?? string.Empty, ke |
| | 0 | 579 | | qty, 0, warehouseNames.GetValueOrDefault(h.WarehouseKey), warehouseNames.GetValueOrDefault(th.WarehouseR |
| | 0 | 580 | | h.DeletionMark, h.Posted)); |
| | | 581 | | } |
| | | 582 | | |
| | 4 | 583 | | movements.Sort((a, b) => b.Date.CompareTo(a.Date)); |
| | | 584 | | |
| | 1 | 585 | | var balances = await balanceTask; |
| | 1 | 586 | | var currentBalance = balances |
| | 1 | 587 | | .Where(b => b.QuantityBalance > 0) |
| | 1 | 588 | | .Select(b => new OneCStockLine |
| | 1 | 589 | | { |
| | 1 | 590 | | Warehouse = warehouseNames.GetValueOrDefault(b.WarehouseKey, b.WarehouseKey.ToString()), |
| | 1 | 591 | | Quantity = b.QuantityBalance |
| | 1 | 592 | | }) |
| | 1 | 593 | | .OrderBy(s => s.Warehouse) |
| | 1 | 594 | | .ToList(); |
| | | 595 | | |
| | 1 | 596 | | var currentReserve = reserveRows |
| | 0 | 597 | | .Where(r => reserveHdrs.TryGetValue(r.RefKey, out var header) && header.Posted && !header.DeletionMark) |
| | 0 | 598 | | .SelectMany(r => ReserveWarehouseDeltas(r, warehouseNames)) |
| | 0 | 599 | | .GroupBy(r => r.Warehouse, StringComparer.OrdinalIgnoreCase) |
| | 0 | 600 | | .Select(g => new OneCStockLine { Warehouse = g.Key, Quantity = g.Sum(r => r.Quantity) }) |
| | 0 | 601 | | .Where(r => r.Quantity != 0) |
| | 0 | 602 | | .OrderBy(r => r.Warehouse) |
| | 1 | 603 | | .ToList(); |
| | | 604 | | |
| | 1 | 605 | | var currentReserveLines = reserveRows |
| | 0 | 606 | | .Where(r => reserveHdrs.TryGetValue(r.RefKey, out var header) && header.Posted && !header.DeletionMark) |
| | 0 | 607 | | .SelectMany(r => ReserveWarehouseDeltas(r, warehouseNames) |
| | 0 | 608 | | .Select(delta => new OneCReserveLine(r.RefKey, r.LineNumber, delta.Warehouse ?? string.Empty, delta.Quan |
| | 0 | 609 | | .Where(r => !string.IsNullOrWhiteSpace(r.Warehouse) && r.Quantity > 0) |
| | 0 | 610 | | .OrderBy(r => r.Warehouse) |
| | 0 | 611 | | .ThenBy(r => r.DocumentRefKey) |
| | 0 | 612 | | .ThenBy(r => r.LineNumber) |
| | 1 | 613 | | .ToList(); |
| | | 614 | | |
| | 4 | 615 | | var active = movements.Where(m => !m.IsDeleted).ToList(); |
| | 1 | 616 | | return new MovementMapResult |
| | 1 | 617 | | { |
| | 1 | 618 | | Article = trimmed, |
| | 1 | 619 | | NomenclatureName = nomName, |
| | 1 | 620 | | CurrentBalance = currentBalance, |
| | 1 | 621 | | CurrentReserve = currentReserve, |
| | 1 | 622 | | CurrentReserveLines = currentReserveLines, |
| | 3 | 623 | | TotalReceiptQty = (int)active.Where(m => m.Kind == MovementKind.Receipt).Sum(m => m.Quantity), |
| | 2 | 624 | | TotalReceiptDocs = active.Count(m => m.Kind == MovementKind.Receipt), |
| | 3 | 625 | | TotalExpenseQty = (int)active.Where(m => m.Kind == MovementKind.Expense).Sum(m => m.Quantity), |
| | 2 | 626 | | TotalExpenseDocs = active.Count(m => m.Kind == MovementKind.Expense), |
| | 2 | 627 | | TotalTransferQty = (int)active.Where(m => m.Kind == MovementKind.Transfer).Sum(m => m.Quantity), |
| | 2 | 628 | | TotalTransferDocs = active.Count(m => m.Kind == MovementKind.Transfer), |
| | 1 | 629 | | Movements = movements |
| | 1 | 630 | | }; |
| | 14 | 631 | | } |
| | | 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 | | { |
| | 0 | 643 | | if (lineNumber <= 0) throw new ArgumentOutOfRangeException(nameof(lineNumber)); |
| | 0 | 644 | | if (newQuantity < 0) throw new ArgumentOutOfRangeException(nameof(newQuantity), "Количество не может быть отрица |
| | | 645 | | |
| | 0 | 646 | | var nomenclatureKey = await _referenceService.GetNomenclatureGuidByArticleAsync(article.Trim()); |
| | 0 | 647 | | if (nomenclatureKey == null) throw new InvalidOperationException("Номенклатура не найдена в 1С."); |
| | | 648 | | |
| | | 649 | | const string endpoint = "Document_РезервированиеЗапасов"; |
| | 0 | 650 | | var rawDocument = await _odataClient.GetRawJsonAsync(endpoint, documentRefKey); |
| | 0 | 651 | | var original = JObject.Parse(rawDocument); |
| | 0 | 652 | | if (original["Ref_Key"] == null) |
| | 0 | 653 | | throw new InvalidOperationException("Документ резерва не найден в 1С."); |
| | 0 | 654 | | if (original.Value<bool?>("DeletionMark") == true) |
| | 0 | 655 | | throw new InvalidOperationException("Документ резерва помечен на удаление."); |
| | | 656 | | |
| | 0 | 657 | | var lines = original["Запасы"] as JArray |
| | 0 | 658 | | ?? throw new InvalidOperationException("В документе резерва отсутствуют строки запасов."); |
| | 0 | 659 | | var line = lines.OfType<JObject>().FirstOrDefault(x => x.Value<int?>("LineNumber") == lineNumber) |
| | 0 | 660 | | ?? throw new InvalidOperationException("Строка резерва не найдена."); |
| | 0 | 661 | | if (!Guid.TryParse(line.Value<string>("Номенклатура_Key"), out var lineNomenclatureKey) |
| | 0 | 662 | | || lineNomenclatureKey != nomenclatureKey.Value) |
| | 0 | 663 | | throw new InvalidOperationException("Строка резерва не относится к товару этого заказа."); |
| | | 664 | | |
| | 0 | 665 | | var oldQuantity = line.Value<decimal?>("Количество") ?? 0m; |
| | 0 | 666 | | if (oldQuantity == newQuantity) return (oldQuantity, newQuantity); |
| | | 667 | | |
| | 0 | 668 | | var wasPosted = original.Value<bool?>("Posted") == true; |
| | 0 | 669 | | var updateDocument = PrepareDocumentForUpdate((JObject)original.DeepClone()); |
| | 0 | 670 | | var updateLine = ((JArray)updateDocument["Запасы"]!).OfType<JObject>() |
| | 0 | 671 | | .First(x => x.Value<int?>("LineNumber") == lineNumber); |
| | 0 | 672 | | updateLine["Количество"] = newQuantity; |
| | 0 | 673 | | updateDocument["Posted"] = false; |
| | | 674 | | |
| | 0 | 675 | | var unposted = false; |
| | | 676 | | try |
| | | 677 | | { |
| | 0 | 678 | | if (wasPosted) |
| | | 679 | | { |
| | 0 | 680 | | await _odataClient.PatchAsync<ODataDocHeader>(endpoint, documentRefKey, new { Posted = false }); |
| | 0 | 681 | | unposted = true; |
| | | 682 | | } |
| | | 683 | | |
| | 0 | 684 | | await _odataClient.PutAsync(endpoint, documentRefKey, updateDocument); |
| | 0 | 685 | | if (wasPosted) |
| | 0 | 686 | | await _odataClient.PostDocumentAsync(endpoint, documentRefKey); |
| | 0 | 687 | | } |
| | 0 | 688 | | catch |
| | | 689 | | { |
| | 0 | 690 | | if (unposted) |
| | 0 | 691 | | await TryRestoreReserveDocumentAsync(endpoint, documentRefKey, original, wasPosted); |
| | 0 | 692 | | throw; |
| | | 693 | | } |
| | | 694 | | |
| | 0 | 695 | | return (oldQuantity, newQuantity); |
| | 0 | 696 | | } |
| | | 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 | | { |
| | 0 | 705 | | if (newQuantity < 0) throw new ArgumentOutOfRangeException(nameof(newQuantity), "Остаток не может быть отрицател |
| | 0 | 706 | | var stock = await GetStockByArticleDirectAsync(article); |
| | 0 | 707 | | var oldQuantity = stock.FirstOrDefault(s => string.Equals(s.Warehouse, warehouseName, StringComparison.OrdinalIg |
| | 0 | 708 | | if (oldQuantity == newQuantity) return (oldQuantity, newQuantity, string.Empty); |
| | | 709 | | |
| | 0 | 710 | | var nomenclatureKey = await _referenceService.GetNomenclatureGuidByArticleAsync(article.Trim()) |
| | 0 | 711 | | ?? throw new InvalidOperationException("Номенклатура не найдена в 1С."); |
| | 0 | 712 | | var warehouseKey = await _referenceService.GetWarehouseGuidByNameAsync(warehouseName) |
| | 0 | 713 | | ?? throw new InvalidOperationException("Склад не найден в 1С."); |
| | 0 | 714 | | var organizationKey = await _referenceService.GetOrganizationGuidByInnAsync("0000000000") |
| | 0 | 715 | | ?? throw new InvalidOperationException("Организация Интерпартс не найдена в 1С."); |
| | 0 | 716 | | var template = await _odataClient.GetSinglePageAsync<ODataInventoryDocumentTemplate>( |
| | 0 | 717 | | "Document_ИнвентаризацияЗапасов", top: 1, |
| | 0 | 718 | | queryParams: new Dictionary<string, string> { ["$select"] = "ХозяйственнаяОперация_Key" }); |
| | 0 | 719 | | var operationKey = template.FirstOrDefault()?.OperationKey ?? Guid.Empty; |
| | 0 | 720 | | if (operationKey == Guid.Empty) throw new InvalidOperationException("Не найдена хозяйственная операция для инвен |
| | | 721 | | |
| | 0 | 722 | | var delta = newQuantity - oldQuantity; |
| | 0 | 723 | | var request = new ODataInventoryAdjustmentRequest |
| | 0 | 724 | | { |
| | 0 | 725 | | Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), |
| | 0 | 726 | | OrganizationKey = organizationKey, |
| | 0 | 727 | | WarehouseKey = warehouseKey, |
| | 0 | 728 | | OperationKey = operationKey, |
| | 0 | 729 | | Comment = $"Корректировка остатка из Studio2. Причина: {reason.Trim()}", |
| | 0 | 730 | | Lines = |
| | 0 | 731 | | [ |
| | 0 | 732 | | new ODataInventoryAdjustmentLine |
| | 0 | 733 | | { |
| | 0 | 734 | | NomenclatureKey = nomenclatureKey, |
| | 0 | 735 | | ActualQuantity = newQuantity, |
| | 0 | 736 | | BookQuantity = oldQuantity, |
| | 0 | 737 | | Difference = delta, |
| | 0 | 738 | | Surplus = Math.Max(delta, 0m), |
| | 0 | 739 | | Shortage = Math.Max(-delta, 0m) |
| | 0 | 740 | | } |
| | 0 | 741 | | ] |
| | 0 | 742 | | }; |
| | | 743 | | const string endpoint = "Document_ИнвентаризацияЗапасов"; |
| | 0 | 744 | | var document = await _odataClient.PostAsync<ODataInventoryAdjustmentRequest, ODataDocumentResponse>(endpoint, re |
| | 0 | 745 | | await _odataClient.PostDocumentAsync(endpoint, document.RefKey); |
| | 0 | 746 | | var posted = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(endpoint, document.RefKey); |
| | 0 | 747 | | return (oldQuantity, newQuantity, posted?.Number ?? document.Number ?? document.RefKey.ToString()); |
| | 0 | 748 | | } |
| | | 749 | | |
| | | 750 | | private async Task TryRestoreReserveDocumentAsync(string endpoint, Guid documentRefKey, JObject original, bool wasPo |
| | | 751 | | { |
| | | 752 | | try |
| | | 753 | | { |
| | 0 | 754 | | var restoreDocument = PrepareDocumentForUpdate((JObject)original.DeepClone()); |
| | 0 | 755 | | restoreDocument["Posted"] = false; |
| | 0 | 756 | | await _odataClient.PutAsync(endpoint, documentRefKey, restoreDocument); |
| | 0 | 757 | | if (wasPosted) |
| | 0 | 758 | | await _odataClient.PostDocumentAsync(endpoint, documentRefKey); |
| | 0 | 759 | | } |
| | 0 | 760 | | catch (Exception restoreException) |
| | | 761 | | { |
| | 0 | 762 | | _logger.LogCritical(restoreException, |
| | 0 | 763 | | "[Reserve] Не удалось восстановить документ резерва {DocumentRefKey} после ошибки изменения.", documentR |
| | 0 | 764 | | } |
| | 0 | 765 | | } |
| | | 766 | | |
| | | 767 | | private static JObject PrepareDocumentForUpdate(JObject document) |
| | | 768 | | { |
| | 0 | 769 | | document.Remove("Ref_Key"); |
| | 0 | 770 | | document.Remove("DataVersion"); |
| | 0 | 771 | | document.Remove("odata.metadata"); |
| | 0 | 772 | | foreach (var property in document.Properties().Where(x => x.Name.EndsWith("@navigationLinkUrl", StringComparison |
| | 0 | 773 | | property.Remove(); |
| | 0 | 774 | | 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 | | |
| | 0 | 783 | | if (row.SourceReservePlaceKey is Guid sourceKey |
| | 0 | 784 | | && string.Equals(row.SourceReservePlaceType, warehouseType, StringComparison.Ordinal) |
| | 0 | 785 | | && warehouseNames.TryGetValue(sourceKey, out var sourceName) |
| | 0 | 786 | | && !string.IsNullOrWhiteSpace(sourceName)) |
| | | 787 | | { |
| | 0 | 788 | | yield return new OneCStockLine { Warehouse = sourceName, Quantity = row.Quantity }; |
| | | 789 | | } |
| | | 790 | | |
| | 0 | 791 | | if (row.TargetReservePlaceKey is Guid targetKey |
| | 0 | 792 | | && string.Equals(row.TargetReservePlaceType, warehouseType, StringComparison.Ordinal) |
| | 0 | 793 | | && warehouseNames.TryGetValue(targetKey, out var targetName) |
| | 0 | 794 | | && !string.IsNullOrWhiteSpace(targetName)) |
| | | 795 | | { |
| | 0 | 796 | | yield return new OneCStockLine { Warehouse = targetName, Quantity = -row.Quantity }; |
| | | 797 | | } |
| | 0 | 798 | | } |
| | | 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 | | { |
| | 0 | 808 | | var result = new Dictionary<(string, string), (decimal, string?, Guid?, string?)>(); |
| | 0 | 809 | | if (pairs.Count == 0) return result; |
| | | 810 | | |
| | 0 | 811 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | 0 | 812 | | var warehouseGuidByName = warehouses |
| | 0 | 813 | | .Where(w => w.Description != null) |
| | 0 | 814 | | .ToDictionary(w => w.Description!, w => w.RefKey, StringComparer.OrdinalIgnoreCase); |
| | | 815 | | |
| | 0 | 816 | | var uniqueArticles = pairs.Select(p => p.Article).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); |
| | | 817 | | |
| | | 818 | | // Получаем GUIDs номенклатуры параллельно |
| | 0 | 819 | | var nomGuids = new ConcurrentDictionary<string, Guid>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 820 | | await Parallel.ForEachAsync(uniqueArticles, new ParallelOptions { MaxDegreeOfParallelism = 5 }, |
| | 0 | 821 | | async (article, _) => |
| | 0 | 822 | | { |
| | 0 | 823 | | var guid = await _referenceService.GetNomenclatureGuidByArticleAsync(article); |
| | 0 | 824 | | if (guid != null) nomGuids[article] = guid.Value; |
| | 0 | 825 | | }); |
| | | 826 | | |
| | | 827 | | // Для каждого артикула: остатки по складам + последний приходный документ |
| | | 828 | | // Balance отбирается по номенклатуре через Condition, lastReceipt — последний по артикулу в целом |
| | 0 | 829 | | var articleData = new ConcurrentDictionary<string, (List<ODataStockBalance> Balances, string? LastReceiptNumber, |
| | 0 | 830 | | StringComparer.OrdinalIgnoreCase); |
| | | 831 | | |
| | 0 | 832 | | await Parallel.ForEachAsync(uniqueArticles, new ParallelOptions { MaxDegreeOfParallelism = 3 }, |
| | 0 | 833 | | async (article, _) => |
| | 0 | 834 | | { |
| | 0 | 835 | | if (!nomGuids.TryGetValue(article, out var nomGuid)) return; |
| | 0 | 836 | | var nomFilter = $"Номенклатура_Key eq guid'{nomGuid}'"; |
| | 0 | 837 | | |
| | 0 | 838 | | var balancesTask = _odataClient.GetListAsync<ODataStockBalance>( |
| | 0 | 839 | | OneCRegisterEndpoint.WithCondition( |
| | 0 | 840 | | "AccumulationRegister_ЗапасыНаСкладах/Balance", nomFilter)); |
| | 0 | 841 | | |
| | 0 | 842 | | var tabRowsTask = _odataClient.GetSinglePageAsync<ODataMovementTabRow>( |
| | 0 | 843 | | "Document_ПриходнаяНакладная_Запасы", top: 500, |
| | 0 | 844 | | queryParams: new Dictionary<string, string> |
| | 0 | 845 | | { |
| | 0 | 846 | | ["$filter"] = nomFilter, |
| | 0 | 847 | | ["$select"] = "Ref_Key,Количество" |
| | 0 | 848 | | }); |
| | 0 | 849 | | |
| | 0 | 850 | | await Task.WhenAll(balancesTask, tabRowsTask); |
| | 0 | 851 | | |
| | 0 | 852 | | var docKeys = (await tabRowsTask).Select(r => r.RefKey).Distinct().ToList(); |
| | 0 | 853 | | string? lastReceiptNumber = null; |
| | 0 | 854 | | Guid? lastReceiptRefKey = null; |
| | 0 | 855 | | DateTime lastDate = DateTime.MinValue; |
| | 0 | 856 | | |
| | 0 | 857 | | foreach (var batch in docKeys.Chunk(20)) |
| | 0 | 858 | | { |
| | 0 | 859 | | try |
| | 0 | 860 | | { |
| | 0 | 861 | | var orFilter = string.Join(" or ", batch.Select(k => $"Ref_Key eq guid'{k}'")); |
| | 0 | 862 | | var headers = await _odataClient.GetSinglePageAsync<ODataDocHeader>( |
| | 0 | 863 | | "Document_ПриходнаяНакладная", top: batch.Length + 5, |
| | 0 | 864 | | queryParams: new Dictionary<string, string> |
| | 0 | 865 | | { |
| | 0 | 866 | | ["$filter"] = orFilter, |
| | 0 | 867 | | ["$select"] = "Ref_Key,Date,Number,DeletionMark,Posted,СтруктурнаяЕдиница_Key" |
| | 0 | 868 | | }); |
| | 0 | 869 | | foreach (var h in headers) |
| | 0 | 870 | | { |
| | 0 | 871 | | if (h.DeletionMark || !h.Posted) continue; |
| | 0 | 872 | | if (!DateTime.TryParse(h.Date, out var d)) continue; |
| | 0 | 873 | | if (d > lastDate) { lastDate = d; lastReceiptNumber = h.Number; lastReceiptRefKey = h.RefKey |
| | 0 | 874 | | } |
| | 0 | 875 | | } |
| | 0 | 876 | | catch (Exception ex) |
| | 0 | 877 | | { |
| | 0 | 878 | | _logger.LogWarning(ex, "[AssemblyList] Ошибка загрузки заголовков приходных для {Article}", arti |
| | 0 | 879 | | } |
| | 0 | 880 | | } |
| | 0 | 881 | | |
| | 0 | 882 | | articleData[article] = (await balancesTask, lastReceiptNumber, lastReceiptRefKey); |
| | 0 | 883 | | }); |
| | | 884 | | |
| | 0 | 885 | | foreach (var (article, warehouseName) in pairs) |
| | | 886 | | { |
| | 0 | 887 | | var key = (article, warehouseName); |
| | 0 | 888 | | if (result.ContainsKey(key)) continue; |
| | | 889 | | |
| | 0 | 890 | | decimal balance = 0; |
| | 0 | 891 | | string? lastReceiptNumber = null; |
| | | 892 | | |
| | 0 | 893 | | Guid? lastReceiptRefKey = null; |
| | 0 | 894 | | string? otherStock = null; |
| | 0 | 895 | | if (articleData.TryGetValue(article, out var data)) |
| | | 896 | | { |
| | 0 | 897 | | Guid? whGuid = warehouseGuidByName.TryGetValue(warehouseName, out var g) ? g : null; |
| | 0 | 898 | | if (whGuid.HasValue) |
| | 0 | 899 | | balance = data.Balances.FirstOrDefault(b => b.WarehouseKey == whGuid.Value)?.QuantityBalance ?? 0; |
| | 0 | 900 | | lastReceiptNumber = data.LastReceiptNumber; |
| | 0 | 901 | | lastReceiptRefKey = data.LastReceiptRefKey; |
| | | 902 | | |
| | | 903 | | // Остатки на других складах (кроме целевого), только > 0 |
| | 0 | 904 | | var guidToName = warehouseGuidByName.ToDictionary(kv => kv.Value, kv => kv.Key); |
| | 0 | 905 | | var others = data.Balances |
| | 0 | 906 | | .Where(b => b.QuantityBalance > 0 && b.WarehouseKey != (whGuid ?? Guid.Empty)) |
| | 0 | 907 | | .Select(b => $"{guidToName.GetValueOrDefault(b.WarehouseKey, b.WarehouseKey.ToString())}: {b.Quantit |
| | 0 | 908 | | .ToList(); |
| | 0 | 909 | | if (others.Count > 0) otherStock = string.Join(", ", others); |
| | | 910 | | } |
| | | 911 | | |
| | 0 | 912 | | result[key] = (balance, lastReceiptNumber, lastReceiptRefKey, otherStock); |
| | | 913 | | } |
| | | 914 | | |
| | 0 | 915 | | return result; |
| | 0 | 916 | | } |
| | | 917 | | |
| | 2 | 918 | | 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 = "[ТЕСТ]"; |
| | 0 | 927 | | var log = new List<string>(); |
| | | 928 | | |
| | 0 | 929 | | await CancelTestInEndpoint("Document_ПриходнаяНакладная", testMarker, log); |
| | 0 | 930 | | await CancelTestInEndpoint("Document_ПеремещениеЗапасов", testMarker, log); |
| | | 931 | | |
| | 0 | 932 | | return log; |
| | 0 | 933 | | } |
| | | 934 | | |
| | | 935 | | private async Task CancelTestInEndpoint(string endpoint, string marker, List<string> log) |
| | | 936 | | { |
| | | 937 | | try |
| | | 938 | | { |
| | 0 | 939 | | var docs = await _odataClient.GetListAsync<ODataDocumentWithComment>( |
| | 0 | 940 | | endpoint, |
| | 0 | 941 | | new Dictionary<string, string> |
| | 0 | 942 | | { |
| | 0 | 943 | | ["$select"] = "Ref_Key,Number,Posted,Комментарий", |
| | 0 | 944 | | ["$filter"] = "Posted eq true" |
| | 0 | 945 | | }); |
| | | 946 | | |
| | 0 | 947 | | var testDocs = docs.Where(d => |
| | 0 | 948 | | d.Comment != null && |
| | 0 | 949 | | d.Comment.Contains(marker, StringComparison.OrdinalIgnoreCase)).ToList(); |
| | | 950 | | |
| | 0 | 951 | | if (!testDocs.Any()) |
| | | 952 | | { |
| | 0 | 953 | | log.Add($"{endpoint}: тестовых проведённых документов не найдено."); |
| | 0 | 954 | | return; |
| | | 955 | | } |
| | | 956 | | |
| | 0 | 957 | | foreach (var doc in testDocs) |
| | | 958 | | { |
| | | 959 | | try |
| | | 960 | | { |
| | 0 | 961 | | await _odataClient.PatchAsync<ODataDocumentWithComment>(endpoint, doc.RefKey, new { Posted = false } |
| | 0 | 962 | | log.Add($"✅ {endpoint} {doc.Number} ({doc.RefKey}) — проведение снято."); |
| | 0 | 963 | | } |
| | 0 | 964 | | catch (Exception ex) |
| | | 965 | | { |
| | 0 | 966 | | log.Add($"❌ {endpoint} {doc.Number} ({doc.RefKey}) — ошибка: {ex.Message}"); |
| | 0 | 967 | | } |
| | 0 | 968 | | } |
| | 0 | 969 | | } |
| | 0 | 970 | | catch (Exception ex) |
| | | 971 | | { |
| | 0 | 972 | | log.Add($"❌ Ошибка загрузки {endpoint}: {ex.Message}"); |
| | 0 | 973 | | } |
| | 0 | 974 | | } |
| | | 975 | | } |