| | | 1 | | namespace Services.BitrixStockServices; |
| | | 2 | | |
| | | 3 | | /// <summary>Партийная позиция [N], для которой нужно получить свободный остаток базового артикула.</summary> |
| | | 4 | | /// <param name="Article">Базовый артикул (без суффикса [N] и производителя).</param> |
| | | 5 | | /// <param name="Producer">Производитель из снимка таблицы; без него карточку в 1С не найти.</param> |
| | | 6 | | /// <param name="Stores">Склады 1С, связанные с Ozon-складом партии.</param> |
| | | 7 | | /// <param name="RawQty">Остаток базового артикула по снимку таблицы, штук.</param> |
| | | 8 | | /// <param name="Multiplier">Штук в упаковке.</param> |
| | | 9 | | /// <param name="SkipLive">Сверка с 1С онлайн не нужна (склад обнуляется, нет остатка, производитель не совпал).</param> |
| | | 10 | | public readonly record struct BatchFreeStockRequest( |
| | | 11 | | string Article, |
| | | 12 | | string? Producer, |
| | | 13 | | IReadOnlyList<string> Stores, |
| | | 14 | | int RawQty, |
| | | 15 | | int Multiplier, |
| | | 16 | | bool SkipLive); |
| | | 17 | | |
| | | 18 | | /// <summary>Свободный остаток базового артикула партии, штук.</summary> |
| | | 19 | | /// <param name="ReservedQty">Резерв заказов, который реально вычтен из остатка.</param> |
| | | 20 | | /// <param name="FreeQty">Итог: остаток снимка минус резерв, ограниченный остатком 1С онлайн.</param> |
| | | 21 | | /// <param name="LiveFreeQty">Свободный остаток по 1С онлайн; null — не проверялось или проверить не удалось.</param> |
| | | 22 | | public readonly record struct BatchFreeStockResult(int ReservedQty, int FreeQty, int? LiveFreeQty) |
| | | 23 | | { |
| | | 24 | | /// <summary>1С онлайн показала меньше, чем осталось по снимку за вычетом резерва.</summary> |
| | | 25 | | public bool ClampedByLive => LiveFreeQty.HasValue && FreeQty < RawMinusReserved; |
| | | 26 | | |
| | | 27 | | /// <summary>Остаток снимка минус резерв до сверки с 1С онлайн; заполняется планировщиком.</summary> |
| | | 28 | | public int RawMinusReserved { get; init; } |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | /// <summary> |
| | | 32 | | /// Свободный остаток для партий [N] на этапе 5a. Партия накатывается как floor(остаток базового артикула / N), |
| | | 33 | | /// поэтому остаток должен быть свободным — без резерва заказов и не выше того, что сейчас числится в 1С. |
| | | 34 | | /// Раньше брался сырой остаток снимка Google-таблицы: полностью зарезервированный и уже не лежащий в 1С товар |
| | | 35 | | /// продолжал продаваться упаковками (SF1008=SFX[4]: в таблице 23, резерв 23, на Ozon 5 уп.). |
| | | 36 | | /// </summary> |
| | | 37 | | public static class BitrixStockBatchVariantFreeStock |
| | | 38 | | { |
| | | 39 | | /// <summary>Остаток снимка минус резерв (резерв не больше самого остатка), не меньше нуля.</summary> |
| | | 40 | | public static int FreeAfterReserve(int rawQty, int reservedQty) |
| | | 41 | | { |
| | 26 | 42 | | var raw = Math.Max(0, rawQty); |
| | 26 | 43 | | return raw - Math.Min(Math.Max(0, reservedQty), raw); |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | /// <summary>Остаток к накату: свободный по снимку, ограниченный свободным по 1С онлайн (если он известен).</summary |
| | | 47 | | public static int ApplyLive(int freeAfterReserve, int? liveFreeQty) => |
| | 16 | 48 | | liveFreeQty.HasValue ? BitrixStockLiveStockClamp.Clamp(freeAfterReserve, liveFreeQty.Value) : freeAfterReserve; |
| | | 49 | | |
| | | 50 | | /// <summary> |
| | | 51 | | /// Считает свободный остаток для каждой партии. Результат идёт в том же порядке, что и <paramref name="requests"/>. |
| | | 52 | | /// Сверка с 1С онлайн — fail-open: если 1С недоступна или карточка не найдена, остаётся остаток снимка за вычетом р |
| | | 53 | | /// </summary> |
| | | 54 | | public static async Task<IReadOnlyList<BatchFreeStockResult>> ResolveAsync( |
| | | 55 | | IReadOnlyList<BatchFreeStockRequest> requests, |
| | | 56 | | IReadOnlyDictionary<string, Dictionary<string, int>> reserveMap, |
| | | 57 | | IBitrixStockLiveStockGuard liveGuard, |
| | | 58 | | CancellationToken ct = default) |
| | | 59 | | { |
| | 16 | 60 | | var results = new BatchFreeStockResult[requests.Count]; |
| | 16 | 61 | | var reservedByIndex = new int[requests.Count]; |
| | 16 | 62 | | var toCheck = new Dictionary<string, (LiveStockLine Line, List<int> Indexes)>(StringComparer.OrdinalIgnoreCase); |
| | | 63 | | |
| | 72 | 64 | | for (var i = 0; i < requests.Count; i++) |
| | | 65 | | { |
| | 20 | 66 | | var r = requests[i]; |
| | 20 | 67 | | var reserved = BitrixOzonStockLinkage.GetLinkedReservedQuantity(r.Article, r.Stores, reserveMap); |
| | 20 | 68 | | var actualReserved = Math.Min(reserved, Math.Max(0, r.RawQty)); |
| | 20 | 69 | | var free = FreeAfterReserve(r.RawQty, reserved); |
| | 20 | 70 | | reservedByIndex[i] = actualReserved; |
| | 20 | 71 | | results[i] = new BatchFreeStockResult(actualReserved, free, null) { RawMinusReserved = free }; |
| | | 72 | | |
| | | 73 | | // Нет смысла спрашивать 1С, если упаковка и так не набирается. |
| | 20 | 74 | | if (r.SkipLive || free < Math.Max(1, r.Multiplier) || string.IsNullOrWhiteSpace(r.Producer) || r.Stores.Coun |
| | | 75 | | continue; |
| | | 76 | | |
| | | 77 | | // Одна и та же карточка на нескольких Ozon-складах с одинаковыми складами 1С — один запрос к 1С. |
| | 30 | 78 | | var key = $"{r.Article}|{r.Producer}|{string.Join(',', r.Stores.OrderBy(s => s, StringComparer.OrdinalIgnore |
| | 15 | 79 | | if (!toCheck.TryGetValue(key, out var group)) |
| | | 80 | | { |
| | 12 | 81 | | group = (new LiveStockLine(i, r.Article, r.Producer, r.Stores, actualReserved), new List<int>()); |
| | 12 | 82 | | toCheck[key] = group; |
| | | 83 | | } |
| | | 84 | | |
| | 15 | 85 | | group.Indexes.Add(i); |
| | | 86 | | } |
| | | 87 | | |
| | 16 | 88 | | if (toCheck.Count == 0) |
| | 5 | 89 | | return results; |
| | | 90 | | |
| | | 91 | | IReadOnlyDictionary<long, int> liveFree; |
| | | 92 | | try |
| | | 93 | | { |
| | 23 | 94 | | liveFree = await liveGuard.GetLiveFreeStockAsync(toCheck.Values.Select(g => g.Line).ToList(), ct); |
| | 9 | 95 | | } |
| | 1 | 96 | | catch (OperationCanceledException) |
| | | 97 | | { |
| | 1 | 98 | | throw; |
| | | 99 | | } |
| | 1 | 100 | | catch |
| | | 101 | | { |
| | 1 | 102 | | return results; // fail-open: недоступность 1С не должна останавливать этап 5 |
| | | 103 | | } |
| | | 104 | | |
| | 38 | 105 | | foreach (var (line, indexes) in toCheck.Values) |
| | | 106 | | { |
| | 10 | 107 | | if (!liveFree.TryGetValue(line.LineId, out var live)) |
| | | 108 | | continue; |
| | | 109 | | |
| | 42 | 110 | | foreach (var index in indexes) |
| | | 111 | | { |
| | 12 | 112 | | var free = ApplyLive(results[index].RawMinusReserved, live); |
| | 12 | 113 | | results[index] = new BatchFreeStockResult(reservedByIndex[index], free, live) |
| | 12 | 114 | | { |
| | 12 | 115 | | RawMinusReserved = results[index].RawMinusReserved |
| | 12 | 116 | | }; |
| | | 117 | | } |
| | | 118 | | } |
| | | 119 | | |
| | 9 | 120 | | return results; |
| | 15 | 121 | | } |
| | | 122 | | } |