| | | 1 | | using Microsoft.Extensions.Logging; |
| | | 2 | | using Services.ApiServices._1CApi.OData; |
| | | 3 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 4 | | |
| | | 5 | | namespace Services.ApiServices._1CApi.Stock; |
| | | 6 | | |
| | | 7 | | /// <summary>Строка документа, которую нужно проверить: сколько товара уйдёт с какого склада.</summary> |
| | | 8 | | public sealed record StockCheckLine( |
| | | 9 | | int OrderId, |
| | | 10 | | string? Article, |
| | | 11 | | string? ProductName, |
| | | 12 | | string? ProducerName, |
| | | 13 | | string? WarehouseName, |
| | | 14 | | decimal Quantity); |
| | | 15 | | |
| | | 16 | | public enum StockCheckStatus |
| | | 17 | | { |
| | | 18 | | Ok, |
| | | 19 | | InsufficientStock, |
| | | 20 | | NomenclatureNotFound, |
| | | 21 | | WarehouseNotFound |
| | | 22 | | } |
| | | 23 | | |
| | | 24 | | /// <summary>Результат по одной паре (карточка номенклатуры, склад).</summary> |
| | 147 | 25 | | public sealed record StockShortage( |
| | 157 | 26 | | string Article, |
| | 74 | 27 | | string? ProductName, |
| | 161 | 28 | | string WarehouseName, |
| | 78 | 29 | | decimal Required, |
| | 81 | 30 | | decimal Available, |
| | 160 | 31 | | StockCheckStatus Status, |
| | 157 | 32 | | IReadOnlyList<int> OrderIds); |
| | | 33 | | |
| | | 34 | | public sealed record StockCheckResult( |
| | | 35 | | IReadOnlyList<StockShortage> Lines, |
| | | 36 | | bool CheckSkipped, |
| | | 37 | | string? SkipReason) |
| | | 38 | | { |
| | | 39 | | /// <summary>Только то, что блокирует проведение. Отсутствие карточки и склада — не блокирует.</summary> |
| | | 40 | | public IReadOnlyList<StockShortage> Blocking { get; } = |
| | | 41 | | Lines.Where(l => l.Status == StockCheckStatus.InsufficientStock).ToList(); |
| | | 42 | | |
| | | 43 | | public bool HasBlocking => Blocking.Count > 0; |
| | | 44 | | |
| | | 45 | | public static StockCheckResult Empty { get; } = new(Array.Empty<StockShortage>(), false, null); |
| | | 46 | | |
| | | 47 | | public static StockCheckResult Skipped(string reason) => |
| | | 48 | | new(Array.Empty<StockShortage>(), true, reason); |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | public interface IOneCStockAvailabilityService |
| | | 52 | | { |
| | | 53 | | /// <summary> |
| | | 54 | | /// Спрашивает у 1С текущие остатки и сравнивает их с потребностью документа. |
| | | 55 | | /// Никогда не бросает из-за недоступности 1С — возвращает CheckSkipped=true (fail-open). |
| | | 56 | | /// </summary> |
| | | 57 | | Task<StockCheckResult> CheckAsync(IReadOnlyList<StockCheckLine> lines, CancellationToken ct = default); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | /// <summary> |
| | | 61 | | /// Проверяет остатки в 1С ДО создания документа. Нужно потому, что при нехватке остатка 1С |
| | | 62 | | /// принимает создание документа, но отклоняет проведение, отдавая лишь общее |
| | | 63 | | /// «Не удалось провести "..."!» без указания позиции — детализация видна только внутри 1С. |
| | | 64 | | /// </summary> |
| | | 65 | | public sealed class OneCStockAvailabilityService : IOneCStockAvailabilityService |
| | | 66 | | { |
| | | 67 | | private const string BalanceEndpoint = "AccumulationRegister_ЗапасыНаСкладах/Balance"; |
| | | 68 | | |
| | | 69 | | /// <summary> |
| | | 70 | | /// Сколько GUID номенклатуры кладём в один $filter. Имя поля «Номенклатура_Key» кириллическое, |
| | | 71 | | /// после Uri.EscapeDataString один терм занимает ~150 байт: 20 термов ≈ 3 КБ query string — |
| | | 72 | | /// с запасом ниже риска HTTP 414, но документ на 40 позиций укладывается в 2 запроса вместо 40. |
| | | 73 | | /// </summary> |
| | | 74 | | private const int BalanceChunkSize = 20; |
| | | 75 | | |
| | | 76 | | private readonly IOneCODataClient _client; |
| | | 77 | | private readonly IOneCODataReferenceService _referenceService; |
| | | 78 | | private readonly IOneCNomenclatureService _nomenclatureService; |
| | | 79 | | private readonly ILogger<OneCStockAvailabilityService> _logger; |
| | | 80 | | |
| | | 81 | | public OneCStockAvailabilityService( |
| | | 82 | | IOneCODataClient client, |
| | | 83 | | IOneCODataReferenceService referenceService, |
| | | 84 | | IOneCNomenclatureService nomenclatureService, |
| | | 85 | | ILogger<OneCStockAvailabilityService> logger) |
| | | 86 | | { |
| | | 87 | | _client = client; |
| | | 88 | | _referenceService = referenceService; |
| | | 89 | | _nomenclatureService = nomenclatureService; |
| | | 90 | | _logger = logger; |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | public async Task<StockCheckResult> CheckAsync(IReadOnlyList<StockCheckLine> lines, CancellationToken ct = default) |
| | | 94 | | { |
| | | 95 | | // Те же правила отбора, что у OneCTransferManager при построении строк документа: |
| | | 96 | | // позиции без артикула или с нулевым количеством в документ не попадают. |
| | | 97 | | var valid = (lines ?? Array.Empty<StockCheckLine>()) |
| | | 98 | | .Where(l => !string.IsNullOrWhiteSpace(l.Article) |
| | | 99 | | && !string.IsNullOrWhiteSpace(l.WarehouseName) |
| | | 100 | | && l.Quantity > 0) |
| | | 101 | | .ToList(); |
| | | 102 | | |
| | | 103 | | if (valid.Count == 0) |
| | | 104 | | return StockCheckResult.Empty; |
| | | 105 | | |
| | | 106 | | try |
| | | 107 | | { |
| | | 108 | | return await CheckCoreAsync(valid, ct); |
| | | 109 | | } |
| | | 110 | | catch (Exception ex) |
| | | 111 | | { |
| | | 112 | | // Fail-open: 1С недоступна — не блокируем проведение. Настоящий backstop (ошибка |
| | | 113 | | // проведения документа) остаётся на месте, а блокировать создание документов на всё |
| | | 114 | | // время недоступности 1С было бы регрессом относительно текущего поведения. |
| | | 115 | | _logger.LogWarning(ex, "[OData] Проверка остатков не выполнена — продолжаем без неё."); |
| | | 116 | | return StockCheckResult.Skipped(ex.Message); |
| | | 117 | | } |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | private async Task<StockCheckResult> CheckCoreAsync(List<StockCheckLine> lines, CancellationToken ct) |
| | | 121 | | { |
| | | 122 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | | 123 | | var warehouseGuidByName = warehouses |
| | | 124 | | .Where(w => !string.IsNullOrWhiteSpace(w.Description)) |
| | | 125 | | .GroupBy(w => w.Description!.Trim(), StringComparer.OrdinalIgnoreCase) |
| | | 126 | | .ToDictionary(g => g.Key, g => g.First().RefKey, StringComparer.OrdinalIgnoreCase); |
| | | 127 | | |
| | | 128 | | // Резолвим уникальные (артикул, производитель) — тем же путём, которым документ разрешает |
| | | 129 | | // номенклатуру, но без создания карточек. |
| | | 130 | | var lookupKeys = lines |
| | | 131 | | .Select(l => (Article: l.Article!.Trim(), Producer: l.ProducerName?.Trim() ?? string.Empty)) |
| | | 132 | | .Distinct() |
| | | 133 | | .ToList(); |
| | | 134 | | |
| | | 135 | | var found = await _nomenclatureService.FindManyAsync( |
| | | 136 | | lookupKeys.Select(k => ((string?)k.Article, (string?)k.Producer)).ToList()); |
| | | 137 | | |
| | | 138 | | var nomenclatureByKey = new Dictionary<(string Article, string Producer), Guid>(); |
| | | 139 | | for (var i = 0; i < lookupKeys.Count; i++) |
| | | 140 | | { |
| | | 141 | | if (found[i] is { } refKey) |
| | | 142 | | nomenclatureByKey[lookupKeys[i]] = refKey; |
| | | 143 | | } |
| | | 144 | | |
| | | 145 | | // Группируем по (карточка, склад), а не по артикулу: разные написания артикула ведут на одну |
| | | 146 | | // карточку, а один артикул с разными производителями — на две. |
| | | 147 | | var groups = new Dictionary<(Guid Nomenclature, Guid Warehouse), StockGroup>(); |
| | | 148 | | var unresolved = new List<StockShortage>(); |
| | | 149 | | |
| | | 150 | | foreach (var line in lines) |
| | | 151 | | { |
| | | 152 | | var article = line.Article!.Trim(); |
| | | 153 | | var warehouseName = line.WarehouseName!.Trim(); |
| | | 154 | | var key = (article, line.ProducerName?.Trim() ?? string.Empty); |
| | | 155 | | |
| | | 156 | | if (!nomenclatureByKey.TryGetValue(key, out var nomenclatureKey)) |
| | | 157 | | { |
| | | 158 | | unresolved.Add(new StockShortage(article, line.ProductName, warehouseName, |
| | | 159 | | line.Quantity, 0, StockCheckStatus.NomenclatureNotFound, new[] { line.OrderId })); |
| | | 160 | | continue; |
| | | 161 | | } |
| | | 162 | | |
| | | 163 | | if (!warehouseGuidByName.TryGetValue(warehouseName, out var warehouseKey)) |
| | | 164 | | { |
| | | 165 | | unresolved.Add(new StockShortage(article, line.ProductName, warehouseName, |
| | | 166 | | line.Quantity, 0, StockCheckStatus.WarehouseNotFound, new[] { line.OrderId })); |
| | | 167 | | continue; |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | if (!groups.TryGetValue((nomenclatureKey, warehouseKey), out var group)) |
| | | 171 | | { |
| | | 172 | | group = new StockGroup(article, line.ProductName, warehouseName); |
| | | 173 | | groups[(nomenclatureKey, warehouseKey)] = group; |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | group.Required += line.Quantity; |
| | | 177 | | group.OrderIds.Add(line.OrderId); |
| | | 178 | | } |
| | | 179 | | |
| | | 180 | | if (groups.Count == 0) |
| | | 181 | | return new StockCheckResult(unresolved, false, null); |
| | | 182 | | |
| | | 183 | | var balances = await LoadBalancesAsync(groups.Keys.Select(k => k.Nomenclature).Distinct().ToList(), ct); |
| | | 184 | | |
| | | 185 | | var result = new List<StockShortage>(groups.Count + unresolved.Count); |
| | | 186 | | foreach (var ((nomenclatureKey, warehouseKey), group) in groups) |
| | | 187 | | { |
| | | 188 | | // Виртуальная таблица не возвращает нулевые строки — отсутствие пары значит остаток 0. |
| | | 189 | | balances.TryGetValue((nomenclatureKey, warehouseKey), out var available); |
| | | 190 | | result.Add(new StockShortage( |
| | | 191 | | group.Article, |
| | | 192 | | group.ProductName, |
| | | 193 | | group.WarehouseName, |
| | | 194 | | group.Required, |
| | | 195 | | available, |
| | | 196 | | group.Required > available ? StockCheckStatus.InsufficientStock : StockCheckStatus.Ok, |
| | | 197 | | group.OrderIds)); |
| | | 198 | | } |
| | | 199 | | |
| | | 200 | | result.AddRange(unresolved); |
| | | 201 | | return new StockCheckResult(result, false, null); |
| | | 202 | | } |
| | | 203 | | |
| | | 204 | | private async Task<Dictionary<(Guid Nomenclature, Guid Warehouse), decimal>> LoadBalancesAsync( |
| | | 205 | | IReadOnlyList<Guid> nomenclatureKeys, |
| | | 206 | | CancellationToken ct) |
| | | 207 | | { |
| | | 208 | | var balances = new Dictionary<(Guid, Guid), decimal>(); |
| | | 209 | | |
| | | 210 | | // Фильтруем только по номенклатуре: складов в документе обычно 1-2, а OR-of-AND с |
| | | 211 | | // «СтруктурнаяЕдиница_Key» удвоил бы длину фильтра. Склады сводим локально. |
| | | 212 | | foreach (var chunk in nomenclatureKeys.Chunk(BalanceChunkSize)) |
| | | 213 | | { |
| | | 214 | | // Отбор идёт параметром Condition самой виртуальной таблицы: $filter по её измерениям |
| | | 215 | | // 1С Fresh запрещает («Операция не разрешена в предложении "ГДЕ"», HTTP 500). |
| | | 216 | | var condition = string.Join(" or ", chunk.Select(k => $"Номенклатура_Key eq guid'{k}'")); |
| | | 217 | | var rows = await _client.GetListAsync<ODataStockBalance>( |
| | | 218 | | OneCRegisterEndpoint.WithCondition(BalanceEndpoint, condition), |
| | | 219 | | queryParams: null, |
| | | 220 | | ct); |
| | | 221 | | |
| | | 222 | | foreach (var row in rows) |
| | | 223 | | { |
| | | 224 | | var key = (row.NomenclatureKey, row.WarehouseKey); |
| | | 225 | | balances[key] = balances.TryGetValue(key, out var existing) |
| | | 226 | | ? existing + row.QuantityBalance |
| | | 227 | | : row.QuantityBalance; |
| | | 228 | | } |
| | | 229 | | } |
| | | 230 | | |
| | | 231 | | return balances; |
| | | 232 | | } |
| | | 233 | | |
| | | 234 | | private sealed class StockGroup(string article, string? productName, string warehouseName) |
| | | 235 | | { |
| | | 236 | | public string Article { get; } = article; |
| | | 237 | | public string? ProductName { get; } = productName; |
| | | 238 | | public string WarehouseName { get; } = warehouseName; |
| | | 239 | | public decimal Required { get; set; } |
| | | 240 | | public List<int> OrderIds { get; } = []; |
| | | 241 | | } |
| | | 242 | | } |