| | | 1 | | using Microsoft.Extensions.Logging; |
| | | 2 | | using Services.ApiServices._1CApi.Stock; |
| | | 3 | | |
| | | 4 | | namespace Services.BitrixStockServices; |
| | | 5 | | |
| | | 6 | | /// <summary>Строка наката, остаток которой нужно сверить с 1С онлайн.</summary> |
| | | 7 | | /// <param name="Stores">Названия складов 1С, которые суммируются в строку (например B1 и B2 → B12).</param> |
| | | 8 | | /// <param name="ReservedQty">Резерв заказов, уже вычтенный из остатка 1С в отчёте.</param> |
| | | 9 | | public sealed record LiveStockLine(long LineId, string Article, string? Producer, IReadOnlyList<string> Stores, int Rese |
| | | 10 | | |
| | | 11 | | public interface IBitrixStockLiveStockGuard |
| | | 12 | | { |
| | | 13 | | /// <summary> |
| | | 14 | | /// Свободный остаток по 1С прямо сейчас (остаток регистра минус резерв) для каждой строки, которую |
| | | 15 | | /// удалось проверить. Строки без ответа 1С (карточка или склад не найдены, 1С недоступна) в результат |
| | | 16 | | /// не попадают — накат по ним идёт как раньше (fail-open). |
| | | 17 | | /// </summary> |
| | | 18 | | Task<IReadOnlyDictionary<long, int>> GetLiveFreeStockAsync( |
| | | 19 | | IReadOnlyList<LiveStockLine> lines, CancellationToken ct = default); |
| | | 20 | | } |
| | | 21 | | |
| | | 22 | | /// <summary> |
| | | 23 | | /// Страховка этапа 2: остатки отчёта берутся из снимка Google-таблицы, а она может отставать от 1С |
| | | 24 | | /// (товар уже перемещён, в таблице всё ещё числится). Перед накатом остатка вверх сверяем его с регистром 1С. |
| | | 25 | | /// </summary> |
| | | 26 | | public sealed class BitrixStockLiveStockGuard( |
| | | 27 | | IOneCStockAvailabilityService availability, |
| | | 28 | | ILogger<BitrixStockLiveStockGuard> logger) : IBitrixStockLiveStockGuard |
| | | 29 | | { |
| | | 30 | | public async Task<IReadOnlyDictionary<long, int>> GetLiveFreeStockAsync( |
| | | 31 | | IReadOnlyList<LiveStockLine> lines, CancellationToken ct = default) |
| | | 32 | | { |
| | | 33 | | var empty = new Dictionary<long, int>(); |
| | | 34 | | if (lines.Count == 0) |
| | | 35 | | return empty; |
| | | 36 | | |
| | | 37 | | // OrderId в запросе проверки остатков — индекс строки: по нему же сопоставляем ответ. |
| | | 38 | | var checks = new List<StockCheckLine>(); |
| | | 39 | | for (var i = 0; i < lines.Count; i++) |
| | | 40 | | { |
| | | 41 | | foreach (var store in lines[i].Stores.Distinct(StringComparer.OrdinalIgnoreCase)) |
| | | 42 | | checks.Add(new StockCheckLine(i, lines[i].Article, null, lines[i].Producer, store, 1)); |
| | | 43 | | } |
| | | 44 | | |
| | | 45 | | var result = await availability.CheckAsync(checks, ct); |
| | | 46 | | if (result.CheckSkipped) |
| | | 47 | | { |
| | | 48 | | logger.LogWarning("Сверка остатков с 1С пропущена: {Reason}", result.SkipReason); |
| | | 49 | | return empty; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | var liveByIndex = new Dictionary<int, decimal>(); |
| | | 53 | | var unverified = new HashSet<int>(); |
| | | 54 | | foreach (var shortage in result.Lines) |
| | | 55 | | { |
| | | 56 | | foreach (var index in shortage.OrderIds.Distinct()) |
| | | 57 | | { |
| | | 58 | | if (shortage.Status is StockCheckStatus.NomenclatureNotFound or StockCheckStatus.WarehouseNotFound) |
| | | 59 | | { |
| | | 60 | | unverified.Add(index); |
| | | 61 | | continue; |
| | | 62 | | } |
| | | 63 | | |
| | | 64 | | liveByIndex[index] = liveByIndex.GetValueOrDefault(index) + shortage.Available; |
| | | 65 | | } |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | var free = new Dictionary<long, int>(); |
| | | 69 | | foreach (var (index, live) in liveByIndex) |
| | | 70 | | { |
| | | 71 | | if (unverified.Contains(index)) |
| | | 72 | | continue; |
| | | 73 | | free[lines[index].LineId] = BitrixStockLiveStockClamp.FreeStock(live, lines[index].ReservedQty); |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | return free; |
| | | 77 | | } |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | /// <summary>Чистая арифметика защиты — отдельно от обращений к 1С, чтобы тестировать без сети.</summary> |
| | | 81 | | public static class BitrixStockLiveStockClamp |
| | | 82 | | { |
| | | 83 | | /// <summary>Остаток регистра 1С минус резерв заказов, не меньше нуля, целое число штук.</summary> |
| | | 84 | | public static int FreeStock(decimal liveBalance, int reservedQty) => |
| | 7 | 85 | | (int)Math.Max(0m, Math.Floor(liveBalance) - Math.Max(0, reservedQty)); |
| | | 86 | | |
| | | 87 | | /// <summary> |
| | | 88 | | /// Остаток к накату не может превышать свободный остаток по 1С. Возвращает ограниченное значение. |
| | | 89 | | /// </summary> |
| | 19 | 90 | | public static int Clamp(int oneCQty, int freeLiveStock) => Math.Min(oneCQty, Math.Max(0, freeLiveStock)); |
| | | 91 | | |
| | | 92 | | /// <summary>«B1, B2» → [B1, B2]: названия складов 1С, склеенные в одну строку отчёта.</summary> |
| | | 93 | | public static IReadOnlyList<string> SplitStores(string? oneCStore) => |
| | 10 | 94 | | (oneCStore ?? string.Empty) |
| | 10 | 95 | | .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
| | 10 | 96 | | .ToList(); |
| | | 97 | | } |