< Summary

Information
Class: Services.StockSyncServices.WarehouseStockSyncService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/StockSyncServices/WarehouseStockSyncService.cs
Line coverage
52%
Covered lines: 268
Uncovered lines: 238
Coverable lines: 506
Total lines: 853
Line coverage: 52.9%
Branch coverage
48%
Covered branches: 162
Total branches: 336
Branch coverage: 48.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%210%
IsTransientGoogleException(...)70%857085.36%
IsTransientSocketError(...)44.44%1818100%
ExecuteGoogleApiWithRetryAsync()0%620%
SyncFromGoogleSheetAsync()0%156120%
SyncFromExcelAsync()0%156120%
ReadFromGoogleSheetAsync()0%342180%
ReadFromExcelAsync()0%600240%
BuildSnapshots(...)95%2020100%
IsEmptyRow(...)50%8887.5%
IsSummaryRow(...)66.66%6688.88%
TryGetHeaderIndex(...)100%44100%
Cell(...)50%5466.66%
Cell(...)100%88100%
Cell(...)75%191677.77%
ToCellString(...)50%66100%
ParseDecimal(...)100%1212100%
ResolveWarehouse(...)30%401033.33%
BuildRowKey(...)57.14%2828100%
StartRunAsync()0%620%
ReplaceSnapshotAsync()100%210%
<ReplaceSnapshotAsync()0%210140%
BuildTombstones(...)100%1616100%
TombstoneKey(...)50%44100%
MarkRunSuccessAsync()100%210%
MarkRunFailedAsync()0%2040%
BuildServiceAccountJson()0%342180%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/StockSyncServices/WarehouseStockSyncService.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Security.Cryptography;
 3using System.Text;
 4using System.Text.Json;
 5using System.Net.Http;
 6using System.Net.Sockets;
 7using Google;
 8using Google.Apis.Auth.OAuth2;
 9using Google.Apis.Auth.OAuth2.Responses;
 10using Google.Apis.Services;
 11using Google.Apis.Sheets.v4;
 12using Microsoft.AspNetCore.Http;
 13using Microsoft.EntityFrameworkCore;
 14using Microsoft.Extensions.Logging;
 15using Services.SystemMonitoringServices;
 16using Microsoft.Extensions.Options;
 17using OfficeOpenXml;
 18using OzonDomains.Models;
 19using OzonRepositories.Context;
 20
 21namespace Services.StockSyncServices;
 22
 23public interface IWarehouseStockSyncService
 24{
 25    Task<WarehouseStockSyncRun> SyncFromGoogleSheetAsync(string? triggeredBy, CancellationToken cancellationToken = defa
 26    Task<WarehouseStockSyncRun> SyncFromExcelAsync(
 27        IFormFile file,
 28        string? triggeredBy,
 29        CancellationToken cancellationToken = default);
 30}
 31
 32public class WarehouseStockSyncService : IWarehouseStockSyncService
 33{
 34    private readonly ApplicationDbContext _context;
 35    private readonly GoogleSheetsStockSyncOptions _options;
 36    private readonly ILogger<WarehouseStockSyncService> _logger;
 37    private readonly ISystemMonitoringSignalWriter? _signals;
 38
 39    // M1 — опциональный склад; отсутствует в Google Sheets "Сводная таблица M2", но поддерживается при наличии колонки.
 140    private static readonly string[][] RequiredHeaderAliases =
 141    [
 142        ["Производитель"],
 143        ["Артикул"],
 144        ["Наименование"],
 145        ["Распродажа"],
 146        ["M2 Кол.", "М2 Кол."],
 147        ["M3 Кол.", "М3 Кол."],
 148        ["B1 Кол.", "В1 Кол."],
 149        ["B2 Кол.", "В2 Кол."],
 150        ["Себест. ед."],
 151        ["Себест. итого"],
 152        ["Цена Sale (ед.)"],
 153        ["Цена учётная (ед.)"],
 154        ["Разница (учётная − Sale)", "Разница (учётная - Sale)"],
 155        ["С0", "C0"],
 156        ["Сумма остатков × С0", "Сумма остатков × C0", "Сумма остатков * С0", "Сумма остатков * C0"],
 157        ["Цена Exist"],
 158        ["С1 (Exist −5%)", "C1 (Exist −5%)", "С1 (Exist -5%)", "C1 (Exist -5%)"],
 159        ["% С1 к С0", "% C1 к C0", "% С1 к C0", "% C1 к С0"]
 160    ];
 61
 062    public WarehouseStockSyncService(
 063        ApplicationDbContext context,
 064        IOptions<GoogleSheetsStockSyncOptions> options,
 065        ILogger<WarehouseStockSyncService> logger,
 066        ISystemMonitoringSignalWriter? signals = null)
 67    {
 068        _context = context;
 069        _options = options.Value;
 070        _logger = logger;
 071        _signals = signals;
 072    }
 73
 74    /// <summary>
 75    /// Задержки между повторными попытками при временных сетевых сбоях Google Sheets API (1с, 2с, 4с).
 76    /// </summary>
 177    private static readonly int[] GoogleApiRetryDelaysMs = [1000, 2000, 4000];
 78
 79    /// <summary>
 80    /// Проверяет, является ли исключение временным (транзиентным) сбоем сети или сервиса Google.
 81    /// Такие сбои возникают при нестабильности связи с sheets.googleapis.com / oauth2.googleapis.com,
 82    /// исчерпании квот (429) или сбоях на стороне Google (5xx) и подлежат повторным попыткам и предупреждениям,
 83    /// а не аварийным ошибкам приложения (#2304, #2306).
 84    /// </summary>
 85    public static bool IsTransientGoogleException(Exception? exception)
 86    {
 1887        if (exception == null)
 188            return false;
 89
 1790        if (exception is AggregateException aggregate)
 91        {
 192            return aggregate.InnerExceptions.Any(IsTransientGoogleException);
 93        }
 94
 1695        if (exception is HttpRequestException httpEx)
 96        {
 1297            if (httpEx.StatusCode.HasValue)
 98            {
 599                var statusCode = (int)httpEx.StatusCode.Value;
 5100                if (statusCode is 429 or 500 or 502 or 503 or 504)
 5101                    return true;
 102            }
 103
 14104            for (var inner = httpEx.InnerException; inner != null; inner = inner.InnerException)
 105            {
 1106                if (inner is SocketException socketEx && IsTransientSocketError(socketEx.SocketErrorCode))
 1107                    return true;
 108
 0109                if (inner is IOException or TimeoutException)
 0110                    return true;
 111            }
 112
 6113            var msg = httpEx.Message;
 6114            if (msg.Contains("Network is unreachable", StringComparison.OrdinalIgnoreCase) ||
 6115                msg.Contains("Connection refused", StringComparison.OrdinalIgnoreCase) ||
 6116                msg.Contains("Connection reset", StringComparison.OrdinalIgnoreCase) ||
 6117                msg.Contains("The SSL connection could not be established", StringComparison.OrdinalIgnoreCase) ||
 6118                msg.Contains("Name or service not known", StringComparison.OrdinalIgnoreCase) ||
 6119                msg.Contains("No such host is known", StringComparison.OrdinalIgnoreCase) ||
 6120                msg.Contains("Resource temporarily unavailable", StringComparison.OrdinalIgnoreCase) ||
 6121                msg.Contains("The operation was canceled", StringComparison.OrdinalIgnoreCase))
 122            {
 6123                return true;
 124            }
 125        }
 126
 4127        if (exception is SocketException socket)
 128        {
 0129            return IsTransientSocketError(socket.SocketErrorCode);
 130        }
 131
 4132        if (exception is GoogleApiException googleEx)
 133        {
 1134            var code = (int)googleEx.HttpStatusCode;
 1135            if (code is 429 or 500 or 502 or 503 or 504)
 1136                return true;
 137        }
 138
 3139        if (exception is TokenResponseException)
 140        {
 1141            return true;
 142        }
 143
 2144        if (exception is TimeoutException)
 0145            return true;
 146
 2147        if (exception is IOException ioEx &&
 2148            (ioEx.Message.Contains("transport connection", StringComparison.OrdinalIgnoreCase) ||
 2149             ioEx.Message.Contains("response ended prematurely", StringComparison.OrdinalIgnoreCase)))
 150        {
 0151            return true;
 152        }
 153
 2154        if (exception.InnerException != null && IsTransientGoogleException(exception.InnerException))
 0155            return true;
 156
 2157        return false;
 158    }
 159
 160    private static bool IsTransientSocketError(SocketError code) =>
 1161        code is SocketError.NetworkUnreachable
 1162            or SocketError.HostUnreachable
 1163            or SocketError.ConnectionReset
 1164            or SocketError.ConnectionAborted
 1165            or SocketError.TimedOut
 1166            or SocketError.HostNotFound
 1167            or SocketError.TryAgain
 1168            or SocketError.NoData
 1169            or SocketError.Shutdown;
 170
 171    private async Task<T> ExecuteGoogleApiWithRetryAsync<T>(
 172        Func<Task<T>> action,
 173        string operationName,
 174        CancellationToken cancellationToken)
 175    {
 0176        var maxAttempts = GoogleApiRetryDelaysMs.Length + 1;
 0177        for (var attempt = 1; attempt <= maxAttempts; attempt++)
 178        {
 0179            cancellationToken.ThrowIfCancellationRequested();
 180            try
 181            {
 0182                return await action();
 183            }
 0184            catch (Exception ex) when (attempt < maxAttempts && IsTransientGoogleException(ex))
 185            {
 0186                var delayMs = GoogleApiRetryDelaysMs[attempt - 1];
 0187                _logger.LogInformation(
 0188                    ex,
 0189                    "Временный сбой при запросе к Google Sheets API ({Operation}), попытка {Attempt}/{MaxAttempts}. Повт
 0190                    operationName,
 0191                    attempt,
 0192                    maxAttempts,
 0193                    delayMs);
 0194                await Task.Delay(delayMs, cancellationToken);
 195            }
 196        }
 197
 0198        throw new InvalidOperationException($"Не удалось выполнить {operationName} после {maxAttempts} попыток.");
 0199    }
 200
 201    public async Task<WarehouseStockSyncRun> SyncFromGoogleSheetAsync(
 202        string? triggeredBy,
 203        CancellationToken cancellationToken = default)
 204    {
 0205        var run = await StartRunAsync(
 0206            WarehouseStockSyncSourceType.GoogleSheets,
 0207            triggeredBy,
 0208            null,
 0209            cancellationToken);
 210
 211        try
 212        {
 0213            var rows = await ReadFromGoogleSheetAsync(cancellationToken);
 0214            if (rows.Count == 0)
 0215                throw new InvalidOperationException(
 0216                    "Google Sheets вернул 0 строк данных. Синхронизация остановлена, чтобы не очищать текущий snapshot."
 0217            await ReplaceSnapshotAsync(run, rows, cancellationToken);
 0218            await MarkRunSuccessAsync(run, rows.Count, cancellationToken);
 0219            await (_signals?.RecordAsync("warehouse-stock-import", "success", "google", cancellationToken: cancellationT
 0220            return run;
 221        }
 0222        catch (Exception ex)
 223        {
 0224            await MarkRunFailedAsync(run, ex, cancellationToken);
 0225            await (_signals?.RecordAsync("warehouse-stock-import", "failure", "google", "Ошибка загрузки остатков", canc
 226
 0227            if (IsTransientGoogleException(ex))
 228            {
 229                // Транзиентный сетевой сбой внешнего сервиса: результат уже зафиксирован в БД (run.Status = Failed, run
 230                // Не пробрасываем наружу, чтобы не ронять фоновый джоб с JobPerformanceException (#2137, #2143).
 0231                return run;
 232            }
 233
 0234            throw;
 0235        }
 0236    }
 237
 238    public async Task<WarehouseStockSyncRun> SyncFromExcelAsync(
 239        IFormFile file,
 240        string? triggeredBy,
 241        CancellationToken cancellationToken = default)
 242    {
 0243        if (file == null || file.Length == 0)
 0244            throw new InvalidOperationException("Файл пустой.");
 245
 0246        var run = await StartRunAsync(
 0247            WarehouseStockSyncSourceType.ExcelManual,
 0248            triggeredBy,
 0249            file.FileName,
 0250            cancellationToken);
 251
 252        try
 253        {
 0254            var rows = await ReadFromExcelAsync(file, cancellationToken);
 0255            await ReplaceSnapshotAsync(run, rows, cancellationToken);
 0256            await MarkRunSuccessAsync(run, rows.Count, cancellationToken);
 0257            await (_signals?.RecordAsync("warehouse-stock-import", "success", "excel", cancellationToken: cancellationTo
 0258            return run;
 259        }
 0260        catch (Exception ex)
 261        {
 0262            await MarkRunFailedAsync(run, ex, cancellationToken);
 0263            await (_signals?.RecordAsync("warehouse-stock-import", "failure", "excel", "Ошибка импорта Excel", cancellat
 0264            throw;
 265        }
 0266    }
 267
 268    private async Task<List<WarehouseStockSnapshot>> ReadFromGoogleSheetAsync(CancellationToken cancellationToken)
 269    {
 0270        if (string.IsNullOrWhiteSpace(_options.SpreadsheetId))
 0271            throw new InvalidOperationException("Не указан GoogleSheetsStockSync:SpreadsheetId.");
 0272        var serviceAccountJson = BuildServiceAccountJson();
 0273        if (string.IsNullOrWhiteSpace(serviceAccountJson))
 0274            throw new InvalidOperationException(
 0275                "Не указан сервисный аккаунт Google Sheets. Заполните GoogleSheetsStockSync:Local:ServiceAccountJson " +
 0276                "или GoogleSheetsStockSync:Prod:ServiceAccountJson (либо соответствующие поля ServiceAccount:*).");
 277
 0278        var credential = GoogleCredential
 0279            .FromJson(serviceAccountJson)
 0280            .CreateScoped(SheetsService.Scope.SpreadsheetsReadonly);
 281
 0282        var sheetsService = new SheetsService(new BaseClientService.Initializer
 0283        {
 0284            HttpClientInitializer = credential,
 0285            ApplicationName = "Studio2Prod WarehouseStockSync"
 0286        });
 287
 0288        var metadata = await ExecuteGoogleApiWithRetryAsync(
 0289            () => sheetsService.Spreadsheets.Get(_options.SpreadsheetId).ExecuteAsync(cancellationToken),
 0290            "Spreadsheets.Get",
 0291            cancellationToken);
 292
 0293        var firstSheetName = !string.IsNullOrWhiteSpace(_options.FirstSheetName)
 0294            ? _options.FirstSheetName
 0295            : metadata.Sheets.FirstOrDefault()?.Properties?.Title;
 296
 0297        if (string.IsNullOrWhiteSpace(firstSheetName))
 0298            throw new InvalidOperationException("Не удалось определить первый лист Google Sheets.");
 299
 0300        var escapedSheetName = firstSheetName.Replace("'", "''");
 0301        var range = $"'{escapedSheetName}'!2:100000";
 0302        var valuesResponse = await ExecuteGoogleApiWithRetryAsync(
 0303            () => sheetsService.Spreadsheets.Values.Get(_options.SpreadsheetId, range).ExecuteAsync(cancellationToken),
 0304            "Spreadsheets.Values.Get",
 0305            cancellationToken);
 306
 0307        var rows = valuesResponse.Values?.ToList() ?? new List<IList<object>>();
 0308        if (rows.Count == 0)
 0309            return new List<WarehouseStockSnapshot>();
 310
 0311        var headers = rows[0].Select(ToCellString).ToList();
 0312        var sourceRows = rows.Skip(1).ToList();
 0313        return BuildSnapshots(headers, sourceRows, firstSheetName, WarehouseStockSyncSourceType.GoogleSheets);
 0314    }
 315
 316    private static async Task<List<WarehouseStockSnapshot>> ReadFromExcelAsync(
 317        IFormFile file,
 318        CancellationToken cancellationToken)
 319    {
 0320        var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
 0321        if (ext != ".xlsx")
 0322            throw new InvalidOperationException("Поддерживается только формат .xlsx для ручного обновления.");
 323
 0324        await using var stream = new MemoryStream();
 0325        await file.CopyToAsync(stream, cancellationToken);
 0326        stream.Position = 0;
 327
 0328        ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
 0329        using var package = new ExcelPackage(stream);
 0330        var worksheet = package.Workbook.Worksheets.FirstOrDefault();
 0331        if (worksheet?.Dimension == null)
 0332            return new List<WarehouseStockSnapshot>();
 333
 0334        var rowsCount = worksheet.Dimension.Rows;
 0335        var columnsCount = worksheet.Dimension.Columns;
 0336        if (rowsCount < 2)
 0337            return new List<WarehouseStockSnapshot>();
 338
 0339        var headers = new List<string>(columnsCount);
 0340        for (var c = 1; c <= columnsCount; c++)
 0341            headers.Add((worksheet.Cells[2, c].Text ?? string.Empty).Trim());
 342
 0343        var sourceRows = new List<IList<object>>();
 0344        for (var r = 3; r <= rowsCount; r++)
 345        {
 0346            var values = new List<object>(columnsCount);
 0347            var hasAny = false;
 0348            for (var c = 1; c <= columnsCount; c++)
 349            {
 0350                var text = (worksheet.Cells[r, c].Text ?? string.Empty).Trim();
 0351                values.Add(text);
 0352                if (!string.IsNullOrWhiteSpace(text))
 0353                    hasAny = true;
 354            }
 355
 0356            if (hasAny)
 0357                sourceRows.Add(values);
 358        }
 359
 0360        return BuildSnapshots(headers, sourceRows, worksheet.Name, WarehouseStockSyncSourceType.ExcelManual);
 0361    }
 362
 363    internal static List<WarehouseStockSnapshot> BuildSnapshots(
 364        IReadOnlyList<string> headers,
 365        IReadOnlyList<IList<object>> rows,
 366        string sheetName,
 367        string sourceType)
 368    {
 4369        var headerMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
 154370        for (var i = 0; i < headers.Count; i++)
 371        {
 73372            var h = (headers[i] ?? string.Empty).Trim();
 73373            if (!string.IsNullOrWhiteSpace(h))
 374            {
 73375                if (!headerMap.ContainsKey(h))
 73376                    headerMap[h] = i;
 377
 73378                var normalized = h.Replace('\u00A0', ' ').Replace('−', '-').Trim();
 73379                if (!headerMap.ContainsKey(normalized))
 6380                    headerMap[normalized] = i;
 381            }
 382        }
 383
 119384        foreach (var aliases in RequiredHeaderAliases)
 385        {
 56386            if (!TryGetHeaderIndex(headerMap, aliases, out _))
 1387                throw new InvalidOperationException($"В источнике отсутствует обязательная колонка '{aliases[0]}'.");
 388        }
 389
 3390        var now = DateTime.UtcNow;
 3391        var result = new List<WarehouseStockSnapshot>(rows.Count);
 12392        for (var i = 0; i < rows.Count; i++)
 393        {
 3394            var row = rows[i];
 3395            var manufacturer = Cell(row, headerMap, "Производитель");
 3396            var article = Cell(row, headerMap, "Артикул");
 3397            var productName = Cell(row, headerMap, "Наименование");
 3398            var saleFlag = Cell(row, headerMap, "Распродажа");
 399
 3400            var m2 = ParseDecimal(Cell(row, headerMap, "M2 Кол.", "М2 Кол."));
 3401            var m1 = ParseDecimal(Cell(row, headerMap, "M1 Кол.", "М1 Кол."));
 3402            var m3 = ParseDecimal(Cell(row, headerMap, "M3 Кол.", "М3 Кол."));
 3403            var b1 = ParseDecimal(Cell(row, headerMap, "B1 Кол.", "В1 Кол."));
 3404            var b2 = ParseDecimal(Cell(row, headerMap, "B2 Кол.", "В2 Кол."));
 405
 3406            var unitCost = ParseDecimal(Cell(row, headerMap, "Себест. ед."));
 3407            var totalCost = ParseDecimal(Cell(row, headerMap, "Себест. итого"));
 3408            var salePriceUnit = ParseDecimal(Cell(row, headerMap, "Цена Sale (ед.)"));
 3409            var accountingPriceUnit = ParseDecimal(Cell(row, headerMap, "Цена учётная (ед.)"));
 3410            var priceDiffAccountingMinusSale = ParseDecimal(Cell(row, headerMap, "Разница (учётная − Sale)", "Разница (у
 3411            var c0 = ParseDecimal(Cell(row, headerMap, "С0", "C0"));
 3412            var stockAmountByC0 = ParseDecimal(Cell(row, headerMap, "Сумма остатков × С0", "Сумма остатков × C0", "Сумма
 3413            var existPrice = ParseDecimal(Cell(row, headerMap, "Цена Exist"));
 3414            var c1ExistMinus5Percent = ParseDecimal(Cell(row, headerMap, "С1 (Exist −5%)", "C1 (Exist −5%)", "С1 (Exist 
 3415            var c1PercentToC0 = ParseDecimal(Cell(row, headerMap, "% С1 к С0", "% C1 к C0", "% С1 к С0", "% C1 к C0"));
 416
 3417            if (IsSummaryRow(manufacturer, article, productName))
 418                continue;
 419
 3420            if (IsEmptyRow(
 3421                    manufacturer, article, productName, saleFlag,
 3422                    m2, m1, m3, b1, b2,
 3423                    unitCost, totalCost, salePriceUnit, accountingPriceUnit,
 3424                    priceDiffAccountingMinusSale, c0, stockAmountByC0, existPrice,
 3425                    c1ExistMinus5Percent, c1PercentToC0))
 426            {
 427                continue;
 428            }
 429
 3430            result.Add(new WarehouseStockSnapshot
 3431            {
 3432                RowKey = BuildRowKey(sheetName, i + 3, manufacturer, article, productName, saleFlag, m2, m1, m3, b1, b2)
 3433                SourceRowNumber = i + 3,
 3434                SourceSheetName = sheetName,
 3435                SourceType = sourceType,
 3436                Manufacturer = manufacturer,
 3437                Article = article,
 3438                ProductName = productName,
 3439                SaleFlag = saleFlag,
 3440                M2Qty = m2,
 3441                M1Qty = m1,
 3442                M3Qty = m3,
 3443                B1Qty = b1,
 3444                B2Qty = b2,
 3445                UnitCost = unitCost,
 3446                TotalCost = totalCost,
 3447                SalePriceUnit = salePriceUnit,
 3448                AccountingPriceUnit = accountingPriceUnit,
 3449                PriceDiffAccountingMinusSale = priceDiffAccountingMinusSale,
 3450                C0 = c0,
 3451                StockAmountByC0 = stockAmountByC0,
 3452                ExistPrice = existPrice,
 3453                C1ExistMinus5Percent = c1ExistMinus5Percent,
 3454                C1PercentToC0 = c1PercentToC0,
 3455                Warehouse = ResolveWarehouse(m2, m1, m3, b1, b2),
 3456                SourceUpdatedAtUtc = now,
 3457                SyncedAtUtc = now
 3458            });
 459        }
 460
 3461        return result;
 462    }
 463
 464    private static bool IsEmptyRow(
 465        string? manufacturer,
 466        string? article,
 467        string? productName,
 468        string? saleFlag,
 469        params decimal?[] values)
 470    {
 3471        var hasText =
 3472            !string.IsNullOrWhiteSpace(manufacturer) ||
 3473            !string.IsNullOrWhiteSpace(article) ||
 3474            !string.IsNullOrWhiteSpace(productName) ||
 3475            !string.IsNullOrWhiteSpace(saleFlag);
 3476        if (hasText)
 3477            return false;
 478
 0479        return values.All(v => (v ?? 0m) == 0m);
 480    }
 481
 482    private static bool IsSummaryRow(string? manufacturer, string? article, string? productName)
 483    {
 3484        var cells = new[] { manufacturer, article, productName };
 24485        foreach (var cell in cells)
 486        {
 9487            var normalized = (cell ?? string.Empty)
 9488                .Trim()
 9489                .Replace(" ", string.Empty, StringComparison.Ordinal)
 9490                .ToUpperInvariant();
 9491            if (normalized == "ИТОГО")
 0492                return true;
 493        }
 494
 3495        return false;
 496    }
 497
 498    private static bool TryGetHeaderIndex(IReadOnlyDictionary<string, int> map, string[] aliases, out int index)
 499    {
 136500        for (var i = 0; i < aliases.Length; i++)
 501        {
 67502            if (map.TryGetValue(aliases[i], out index))
 55503                return true;
 504        }
 505
 1506        index = -1;
 1507        return false;
 508    }
 509
 510    private static string Cell(IList<object> row, IReadOnlyDictionary<string, int> map, string header)
 511    {
 27512        if (map.TryGetValue(header, out var idx) && idx < row.Count)
 27513            return ToCellString(row[idx]);
 0514        return string.Empty;
 515    }
 516
 517    private static string Cell(IList<object> row, IReadOnlyDictionary<string, int> map, string header1, string header2)
 518    {
 21519        if (map.TryGetValue(header1, out var idx1) && idx1 < row.Count)
 13520            return ToCellString(row[idx1]);
 8521        if (map.TryGetValue(header2, out var idx2) && idx2 < row.Count)
 7522            return ToCellString(row[idx2]);
 1523        return string.Empty;
 524    }
 525
 526    private static string Cell(IList<object> row, IReadOnlyDictionary<string, int> map, string header1, string header2, 
 527    {
 9528        if (map.TryGetValue(header1, out var idx1) && idx1 < row.Count)
 6529            return ToCellString(row[idx1]);
 3530        if (map.TryGetValue(header2, out var idx2) && idx2 < row.Count)
 2531            return ToCellString(row[idx2]);
 1532        if (map.TryGetValue(header3, out var idx3) && idx3 < row.Count)
 0533            return ToCellString(row[idx3]);
 1534        if (map.TryGetValue(header4, out var idx4) && idx4 < row.Count)
 1535            return ToCellString(row[idx4]);
 0536        return string.Empty;
 537    }
 538
 539    private static string ToCellString(object? value)
 540    {
 56541        return value?.ToString()?.Trim() ?? string.Empty;
 542    }
 543
 544    /// <summary>Только запятая-разделитель тысяч: «2,146», «-12,345,678» (но не «0,125» и не «2,5»).</summary>
 1545    private static readonly System.Text.RegularExpressions.Regex ThousandsOnlyComma =
 1546        new(@"^-?[1-9]\d{0,2}(,\d{3})+$", System.Text.RegularExpressions.RegexOptions.Compiled);
 547
 548    /// <summary>
 549    /// Разбор числа из ячейки Google Sheets. Таблица отдаёт отформатированные значения в формате en-US:
 550    /// «2,146» — это 2146, «3,363.76» — это 3363.76. Раньше запятая всегда считалась десятичной,
 551    /// из-за чего «2,146» читалось как 2.146, а «3,363.76» (после замены — «3.363.76») не разбиралось
 552    /// вовсе и цена, себестоимость и остаток терялись. Десятичная запятая (русский формат «2,5»,
 553    /// «0,125», «1.234,56») по-прежнему поддерживается.
 554    /// </summary>
 555    internal static decimal? ParseDecimal(string? raw)
 556    {
 71557        if (string.IsNullOrWhiteSpace(raw))
 4558            return null;
 559
 67560        var normalized = raw
 67561            .Replace("\u00A0", string.Empty, StringComparison.Ordinal)
 67562            .Replace(" ", string.Empty, StringComparison.Ordinal)
 67563            .Replace("%", string.Empty, StringComparison.Ordinal)
 67564            .Replace("−", "-", StringComparison.Ordinal);
 565
 67566        var hasComma = normalized.Contains(',');
 67567        var hasDot = normalized.Contains('.');
 67568        if (hasComma && hasDot)
 569        {
 570            // Последний разделитель — десятичный: «3,363.76» (en-US) или «1.234,56» (ru).
 6571            normalized = normalized.LastIndexOf(',') < normalized.LastIndexOf('.')
 6572                ? normalized.Replace(",", string.Empty, StringComparison.Ordinal)
 6573                : normalized.Replace(".", string.Empty, StringComparison.Ordinal).Replace(",", ".", StringComparison.Ord
 574        }
 61575        else if (hasComma)
 576        {
 8577            normalized = ThousandsOnlyComma.IsMatch(normalized)
 8578                ? normalized.Replace(",", string.Empty, StringComparison.Ordinal)
 8579                : normalized.Replace(",", ".", StringComparison.Ordinal);
 580        }
 581
 67582        if (decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
 65583            return value;
 584
 2585        return null;
 586    }
 587
 588    private static string ResolveWarehouse(decimal? m2, decimal? m1, decimal? m3, decimal? b1, decimal? b2)
 589    {
 5590        if ((m2 ?? 0) > 0) return "M2";
 2591        if ((m1 ?? 0) > 0) return "M1";
 0592        if ((m3 ?? 0) > 0) return "M3";
 0593        if ((b1 ?? 0) > 0) return "B1";
 0594        if ((b2 ?? 0) > 0) return "B2";
 0595        return "NONE";
 596    }
 597
 598    private static string BuildRowKey(
 599        string sheetName,
 600        int sourceRowNumber,
 601        string? manufacturer,
 602        string? article,
 603        string? productName,
 604        string? saleFlag,
 605        decimal? m2,
 606        decimal? m1,
 607        decimal? m3,
 608        decimal? b1,
 609        decimal? b2)
 610    {
 3611        var raw = string.Join('|',
 3612            sheetName,
 3613            sourceRowNumber.ToString(CultureInfo.InvariantCulture),
 3614            manufacturer ?? string.Empty,
 3615            article ?? string.Empty,
 3616            productName ?? string.Empty,
 3617            saleFlag ?? string.Empty,
 3618            m2?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
 3619            m1?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
 3620            m3?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
 3621            b1?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
 3622            b2?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
 623
 3624        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
 3625        return Convert.ToHexString(hash);
 626    }
 627
 628    private async Task<WarehouseStockSyncRun> StartRunAsync(
 629        string sourceType,
 630        string? triggeredBy,
 631        string? sourceFileName,
 632        CancellationToken cancellationToken)
 633    {
 0634        var run = new WarehouseStockSyncRun
 0635        {
 0636            SourceType = sourceType,
 0637            Status = WarehouseStockSyncRunStatus.Running,
 0638            StartedAtUtc = DateTime.UtcNow,
 0639            TriggeredBy = string.IsNullOrWhiteSpace(triggeredBy) ? null : triggeredBy.Trim(),
 0640            SourceFileName = sourceFileName
 0641        };
 642
 0643        _context.WarehouseStockSyncRuns.Add(run);
 0644        await _context.SaveChangesAsync(cancellationToken);
 0645        return run;
 0646    }
 647
 648    private async Task ReplaceSnapshotAsync(
 649        WarehouseStockSyncRun run,
 650        List<WarehouseStockSnapshot> rows,
 651        CancellationToken cancellationToken)
 652    {
 0653        run.RowsRead = rows.Count;
 0654        var now = DateTime.UtcNow;
 0655        var strategy = _context.Database.CreateExecutionStrategy();
 0656        await strategy.ExecuteAsync(async () =>
 0657        {
 0658            await using var tx = await _context.Database.BeginTransactionAsync(cancellationToken);
 0659
 0660            var previousRows = await _context.WarehouseStockSnapshots.AsNoTracking()
 0661                .ToListAsync(cancellationToken);
 0662            await _context.WarehouseStockSnapshots.ExecuteDeleteAsync(cancellationToken);
 0663
 0664            foreach (var row in rows)
 0665            {
 0666                row.SyncRunId = run.Id;
 0667                row.SourceUpdatedAtUtc = now;
 0668                row.SyncedAtUtc = now;
 0669            }
 0670
 0671            var tombstones = BuildTombstones(previousRows, rows, now, TombstoneTtl);
 0672            if (tombstones.Count > 0)
 0673            {
 0674                foreach (var tombstone in tombstones)
 0675                    tombstone.SyncRunId = run.Id;
 0676                _logger.LogInformation(
 0677                    "Warehouse stock sync run {RunId}: {Count} артикул(ов) исчезли из источника — сохранены нулевыми стр
 0678                    run.Id, tombstones.Count);
 0679                rows = rows.Concat(tombstones).ToList();
 0680            }
 0681
 0682            if (rows.Count > 0)
 0683                await _context.WarehouseStockSnapshots.AddRangeAsync(rows, cancellationToken);
 0684
 0685            run.RowsSaved = rows.Count;
 0686            await _context.SaveChangesAsync(cancellationToken);
 0687            await tx.CommitAsync(cancellationToken);
 0688        });
 0689    }
 690
 691    /// <summary>Сколько дней нулевая строка исчезнувшего артикула остаётся в снимке.</summary>
 1692    internal static readonly TimeSpan TombstoneTtl = TimeSpan.FromDays(3);
 693
 694    /// <summary>Если в новом источнике меньше этой доли прежних строк — выгрузка считается неполной.</summary>
 695    private const double TombstoneMinSourceRatio = 0.5;
 696
 697    /// <summary>
 698    /// Артикулы, которые были в прошлом снимке с остатком &gt; 0, а в новом источнике пропали,
 699    /// возвращаются нулевыми строками. Отчёт этапа 1 строится только по строкам снимка: без такой
 700    /// строки артикул не попадает в отчёт, остаток на маркетплейсе не обнуляется и товар продаётся
 701    /// при нуле в 1С. Уже существующие «надгробия» переносятся, пока не истёк <paramref name="ttl"/>.
 702    /// При неполной выгрузке (пустой источник или резкое падение числа строк) ничего не добавляется.
 703    /// </summary>
 704    internal static List<WarehouseStockSnapshot> BuildTombstones(
 705        IReadOnlyCollection<WarehouseStockSnapshot> previousRows,
 706        IReadOnlyCollection<WarehouseStockSnapshot> newRows,
 707        DateTime now,
 708        TimeSpan ttl)
 709    {
 11710        var result = new List<WarehouseStockSnapshot>();
 135711        var previousReal = previousRows.Count(r => r.SourceType != WarehouseStockSyncSourceType.Tombstone);
 11712        if (newRows.Count == 0 || newRows.Count < previousReal * TombstoneMinSourceRatio)
 2713            return result;
 714
 9715        var present = newRows
 9716            .Select(TombstoneKey)
 93717            .Where(k => k != null)
 9718            .ToHashSet(StringComparer.Ordinal);
 9719        var emitted = new HashSet<string>(StringComparer.Ordinal);
 720
 222721        foreach (var group in previousRows
 102722                     .Where(r => !string.IsNullOrWhiteSpace(r.Article))
 111723                     .GroupBy(r => TombstoneKey(r)!, StringComparer.Ordinal))
 724        {
 102725            if (present.Contains(group.Key) || !emitted.Add(group.Key))
 726                continue;
 727
 9728            var template = group.First();
 9729            var wasTombstone = template.SourceType == WarehouseStockSyncSourceType.Tombstone;
 730            DateTime disappearedAt;
 9731            if (wasTombstone)
 732            {
 2733                disappearedAt = template.SourceUpdatedAtUtc;
 2734                if (now - disappearedAt >= ttl)
 1735                    continue;
 736            }
 737            else
 738            {
 7739                var hadStock = group.Sum(r =>
 14740                    (r.M1Qty ?? 0m) + (r.M2Qty ?? 0m) + (r.M3Qty ?? 0m) + (r.B1Qty ?? 0m) + (r.B2Qty ?? 0m)) > 0;
 7741                if (!hadStock)
 742                    continue;
 6743                disappearedAt = now;
 744            }
 745
 7746            result.Add(new WarehouseStockSnapshot
 7747            {
 7748                RowKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("tombstone|" + group.Key))),
 7749                SourceRowNumber = template.SourceRowNumber,
 7750                SourceSheetName = template.SourceSheetName,
 7751                SourceType = WarehouseStockSyncSourceType.Tombstone,
 7752                Manufacturer = template.Manufacturer,
 7753                Article = template.Article,
 7754                ProductName = template.ProductName,
 7755                SaleFlag = template.SaleFlag,
 7756                M1Qty = 0m,
 7757                M2Qty = 0m,
 7758                M3Qty = 0m,
 7759                B1Qty = 0m,
 7760                B2Qty = 0m,
 7761                UnitCost = template.UnitCost,
 7762                TotalCost = 0m,
 7763                SalePriceUnit = template.SalePriceUnit,
 7764                AccountingPriceUnit = template.AccountingPriceUnit,
 7765                PriceDiffAccountingMinusSale = template.PriceDiffAccountingMinusSale,
 7766                C0 = template.C0,
 7767                StockAmountByC0 = 0m,
 7768                ExistPrice = template.ExistPrice,
 7769                C1ExistMinus5Percent = template.C1ExistMinus5Percent,
 7770                C1PercentToC0 = template.C1PercentToC0,
 7771                Warehouse = "NONE",
 7772                SourceUpdatedAtUtc = disappearedAt,
 7773                SyncedAtUtc = now
 7774            });
 775        }
 776
 9777        return result;
 778    }
 779
 780    private static string? TombstoneKey(WarehouseStockSnapshot row) =>
 195781        string.IsNullOrWhiteSpace(row.Article)
 195782            ? null
 195783            : $"{row.Article.Trim().ToLowerInvariant()}|{(row.Manufacturer ?? string.Empty).Trim().ToLowerInvariant()}";
 784
 785    private async Task MarkRunSuccessAsync(
 786        WarehouseStockSyncRun run,
 787        int rowCount,
 788        CancellationToken cancellationToken)
 789    {
 0790        run.Status = WarehouseStockSyncRunStatus.Success;
 0791        run.RowsSaved = rowCount;
 0792        run.CompletedAtUtc = DateTime.UtcNow;
 0793        await _context.SaveChangesAsync(cancellationToken);
 0794    }
 795
 796    private async Task MarkRunFailedAsync(
 797        WarehouseStockSyncRun run,
 798        Exception exception,
 799        CancellationToken cancellationToken)
 800    {
 0801        if (IsTransientGoogleException(exception))
 802        {
 0803            _logger.LogWarning(
 0804                exception,
 0805                "Warehouse stock sync run {RunId} пропущен из-за временной недоступности Google Sheets: {Error}",
 0806                run.Id,
 0807                exception.Message);
 808        }
 809        else
 810        {
 0811            _logger.LogError(exception, "Warehouse stock sync run {RunId} failed", run.Id);
 812        }
 813
 0814        run.Status = WarehouseStockSyncRunStatus.Failed;
 0815        run.CompletedAtUtc = DateTime.UtcNow;
 0816        run.ErrorMessage = exception.Message.Length > 4000
 0817            ? exception.Message[..4000]
 0818            : exception.Message;
 0819        await _context.SaveChangesAsync(cancellationToken);
 0820    }
 821
 822    private string BuildServiceAccountJson()
 823    {
 0824        if (!string.IsNullOrWhiteSpace(_options.ServiceAccountJson))
 0825            return _options.ServiceAccountJson.Trim();
 826
 0827        var sa = _options.ServiceAccount;
 0828        if (string.IsNullOrWhiteSpace(sa.ClientEmail) ||
 0829            string.IsNullOrWhiteSpace(sa.PrivateKey) ||
 0830            string.IsNullOrWhiteSpace(sa.ProjectId))
 831        {
 0832            return string.Empty;
 833        }
 834
 0835        var privateKey = sa.PrivateKey.Replace("\\n", "\n", StringComparison.Ordinal);
 0836        var payload = new Dictionary<string, string>
 0837        {
 0838            ["type"] = string.IsNullOrWhiteSpace(sa.Type) ? "service_account" : sa.Type,
 0839            ["project_id"] = sa.ProjectId,
 0840            ["private_key_id"] = sa.PrivateKeyId,
 0841            ["private_key"] = privateKey,
 0842            ["client_email"] = sa.ClientEmail,
 0843            ["client_id"] = sa.ClientId,
 0844            ["auth_uri"] = string.IsNullOrWhiteSpace(sa.AuthUri) ? "https://accounts.google.com/o/oauth2/auth" : sa.Auth
 0845            ["token_uri"] = string.IsNullOrWhiteSpace(sa.TokenUri) ? "https://oauth2.googleapis.com/token" : sa.TokenUri
 0846            ["auth_provider_x509_cert_url"] = string.IsNullOrWhiteSpace(sa.AuthProviderX509CertUrl) ? "https://www.googl
 0847            ["client_x509_cert_url"] = sa.ClientX509CertUrl,
 0848            ["universe_domain"] = string.IsNullOrWhiteSpace(sa.UniverseDomain) ? "googleapis.com" : sa.UniverseDomain
 0849        };
 850
 0851        return JsonSerializer.Serialize(payload);
 852    }
 853}

Methods/Properties

.cctor()
.ctor(OzonRepositories.Context.ApplicationDbContext,Microsoft.Extensions.Options.IOptions`1<Services.StockSyncServices.GoogleSheetsStockSyncOptions>,Microsoft.Extensions.Logging.ILogger`1<Services.StockSyncServices.WarehouseStockSyncService>,Services.SystemMonitoringServices.ISystemMonitoringSignalWriter)
IsTransientGoogleException(System.Exception)
IsTransientSocketError(System.Net.Sockets.SocketError)
ExecuteGoogleApiWithRetryAsync()
SyncFromGoogleSheetAsync()
SyncFromExcelAsync()
ReadFromGoogleSheetAsync()
ReadFromExcelAsync()
BuildSnapshots(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.Collections.Generic.IList`1<System.Object>>,System.String,System.String)
IsEmptyRow(System.String,System.String,System.String,System.String,System.Nullable`1<System.Decimal>[])
IsSummaryRow(System.String,System.String,System.String)
TryGetHeaderIndex(System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Int32>,System.String[],System.Int32&)
Cell(System.Collections.Generic.IList`1<System.Object>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Int32>,System.String)
Cell(System.Collections.Generic.IList`1<System.Object>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Int32>,System.String,System.String)
Cell(System.Collections.Generic.IList`1<System.Object>,System.Collections.Generic.IReadOnlyDictionary`2<System.String,System.Int32>,System.String,System.String,System.String,System.String)
ToCellString(System.Object)
ParseDecimal(System.String)
ResolveWarehouse(System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>)
BuildRowKey(System.String,System.Int32,System.String,System.String,System.String,System.String,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>,System.Nullable`1<System.Decimal>)
StartRunAsync()
ReplaceSnapshotAsync()
<ReplaceSnapshotAsync()
BuildTombstones(System.Collections.Generic.IReadOnlyCollection`1<OzonDomains.Models.WarehouseStockSnapshot>,System.Collections.Generic.IReadOnlyCollection`1<OzonDomains.Models.WarehouseStockSnapshot>,System.DateTime,System.TimeSpan)
TombstoneKey(OzonDomains.Models.WarehouseStockSnapshot)
MarkRunSuccessAsync()
MarkRunFailedAsync()
BuildServiceAccountJson()