< Summary

Information
Class: AspNetCoreGeneratedDocument.Areas_Studio2_Views_BitrixStock_Index
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/BitrixStock/Index.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 93
Coverable lines: 93
Total lines: 6066
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 126
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()0%8930940%
BuildStocksTabUrl()100%210%
BuildClearHistoryTabUrl()100%210%
BuildPushHistoryTabUrl()100%210%
BuildJobLogsUrl()100%210%
BuildSyncTabUrl()100%210%
BuildExceptionsTabUrl()100%210%
<ExecuteAsync()0%4260%
<ExecuteAsync()0%600240%
<ExecuteAsync()100%210%
<ExecuteAsync()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/BitrixStock/Index.cshtml

#LineLine coverage
 1@using OzonDomains.Models.BitrixModels
 2@model OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockViewModel
 3<link rel="stylesheet" href="~/css/Studio2/BitrixStock/bitrix-stock.css" asp-append-version="true"/>
 4<link rel="stylesheet" href="~/css/Studio2/shared/quick-links-chip.css" asp-append-version="true"/>
 05@await Html.PartialAsync("_BsxGridShell")
 6<link rel="stylesheet" href="~/css/Studio2/BitrixStock/bitrix-stock-grid.css" asp-append-version="true"/>
 7@{
 08    var isSyncTab       = string.Equals(Model.ActiveTab, "sync",         StringComparison.OrdinalIgnoreCase);
 09    var isExceptionsTab = string.Equals(Model.ActiveTab, "exceptions",   StringComparison.OrdinalIgnoreCase);
 010    var isClearHistoryTab = string.Equals(Model.ActiveTab, "clearHistory", StringComparison.OrdinalIgnoreCase);
 011    var isPushHistoryTab  = string.Equals(Model.ActiveTab, "pushHistory",  StringComparison.OrdinalIgnoreCase);
 012    var isStocksTab     = !isSyncTab && !isExceptionsTab && !isClearHistoryTab && !isPushHistoryTab;
 013    ViewData["Title"] = isSyncTab
 014        ? "Синхронизация остатков 1С → Ozon (отчёт)"
 015        : isExceptionsTab
 016            ? "Справочник исключений из синхронизации"
 017            : isClearHistoryTab
 018                ? "Запуски обнуления остатков Ozon"
 019                : isPushHistoryTab
 020                    ? "Запуски наката остатков Ozon"
 021                    : "Остатки 1С → Ozon";
 22
 23}
 24
 25@functions
 26{
 27
 28
 29
 30    string BuildStocksTabUrl() =>
 031        Url.Action("Index", "BitrixStock", new { area = "Studio2" })!;
 32
 33    string BuildClearHistoryTabUrl() =>
 034        Url.Action("Index", "BitrixStock", new { area = "Studio2", tab = "clearHistory" })!;
 35
 36    string BuildPushHistoryTabUrl() =>
 037        Url.Action("Index", "BitrixStock", new { area = "Studio2", tab = "pushHistory" })!;
 38
 39    string BuildJobLogsUrl() =>
 040        Url.Action("Index", "JobLogs", new { area = "Studio2", tab = "transactions" })!;
 41
 42    string BuildSyncTabUrl() =>
 043        Url.Action("Index", "BitrixStock", new { area = "Studio2", tab = "sync" })!;
 44
 45    string BuildExceptionsTabUrl() =>
 046        Url.Action("Index", "BitrixStock", new { area = "Studio2", tab = "exceptions" })!;
 47
 48}
 49
 50<script src="~/js/Studio2/Orders/CreateOrderToSupplierTransactionV2/generateInfoRow.js"></script>
 51
 52<div id="loadingSpinner" class="spinner-overlay" style="display: none;">
 53    <div class="spinner-content">
 54        <div class="spinner-border text-white" role="status">
 55            <span class="sr-only">Загрузка...</span>
 56        </div>
 57        <div id="loadingSpinnerText" class="spinner-loading-text">Загрузка...</div>
 58    </div>
 59</div>
 60
 61<!-- Модальное окно для сохранения фильтра -->
 62<div class="modal fade" id="saveFilterModal" tabindex="-1" aria-labelledby="saveFilterModalLabel" aria-hidden="true">
 63    <div class="modal-dialog">
 64        <div class="modal-content">
 65            <div class="modal-header">
 66                <h5 class="modal-title" id="saveFilterModalLabel">
 67                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
 68                         class="bi bi-bookmark-plus me-2" viewBox="0 0 16 16">
 69                        <path
 70                            d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.5 0 
 71                        <path
 72                            d="M8 4a.5.5 0 0 1 .5.5V6H10a.5.5 0 0 1 0 1H8.5v1.5a.5.5 0 0 1-1 0V7H6a.5.5 0 0 1 0-1h1.5V4.
 73                    </svg>
 74                    Сохранить фильтр
 75                </h5>
 76                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 77            </div>
 78            <div class="modal-body">
 79                <div class="mb-3">
 80                    <label for="filterNameInput" class="form-label">Название фильтра</label>
 81                    <input type="text" class="form-control" id="filterNameInput"
 82                           placeholder="Например: Товары со склада Москва" autofocus>
 83                    <div class="form-text small">Укажите понятное название</div>
 84                    <div id="filterNameError" class="invalid-feedback" style="display: none;">
 85                        Введите название фильтра
 86                    </div>
 87                </div>
 88            </div>
 89            <div class="modal-footer">
 90                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 91                <button type="button" class="btn btn-primary" id="confirmSaveFilterBtn">
 92                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
 93                         class="bi bi-check-lg me-1" viewBox="0 0 16 16">
 94                        <path
 95                            d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.0
 96                    </svg>
 97                    Сохранить
 98                </button>
 99            </div>
 100        </div>
 101    </div>
 102</div>
 103
 104<div class="modal fade" id="bitrixModal" tabindex="-1" aria-labelledby="bitrixModalLabel" aria-hidden="true">
 105    <div class="modal-dialog modal-fullscreen modal-dialog-centered modal-dialog-scrollable">
 106        <div class="modal-content">
 107            <div class="modal-header">
 108                <h5 class="modal-title" id="bitrixModalLabel">Данные Bitrix</h5>
 109                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
 110            </div>
 111            <div class="modal-body">
 112                <div id="bitrixModalContent">
 113                    <div class="d-flex justify-content-center align-items-center" style="height:200px;">
 114                        <div class="spinner-border text-primary" role="status">
 115                            <span class="visually-hidden">Загрузка...</span>
 116                        </div>
 117                    </div>
 118                </div>
 119
 120                <!-- Блок для данных Bitrix -->
 121                <div id="bitrixDataSection" class="mb-4" style="display: none;">
 122                    <div id="bitrixTableContainer"></div>
 123                </div>
 124
 125                <div class="mt-4">
 126                    <strong class="bs-modal-section-title">История цен</strong>
 127                    <form id="priceHistoryForm" class="bs-price-form">
 128                        <div class="bs-price-field">
 129                            <label class="form-label" for="modalBitrixId">Bitrix ID</label>
 130                            <input name="BitrixId" class="form-control form-control-sm" id="modalBitrixId"/>
 131                            <span class="text-danger small" id="bitrixIdError"></span>
 132                        </div>
 133                        <div class="bs-price-field">
 134                            <label class="form-label" for="modalArticle">Артикул</label>
 135                            <input name="Article" class="form-control form-control-sm" id="modalArticle" readonly/>
 136                            <span class="text-danger small" id="articleError"></span>
 137                        </div>
 138                        <div class="bs-price-field">
 139                            <label class="form-label">Цена</label>
 140                            <input name="PriceValue" class="form-control form-control-sm text-end" value=""
 141                                   type="number" step="0.01" placeholder="0.00"/>
 142                            <span class="text-danger small" id="priceValueError"></span>
 143                            <input type="hidden" name="Currency" value="RUB">
 144                            <input type="hidden" name="RegistrationPrice" value=""/>
 145                        </div>
 146                        <div class="bs-price-field bs-price-field--wide">
 147                            <label class="form-label">Источник</label>
 148                            <input list="suppliersList" name="ValueSource" class="form-control form-control-sm" placehol
 149                            <datalist id="suppliersList">
 0150                                @foreach (var supplier in Model.AllSuppliers)
 151                                {
 0152                                    <option value="@supplier.Name" data-url="@supplier.Site"></option>
 153                                }
 154                            </datalist>
 155                            <span class="text-danger small" id="valueSourceError"></span>
 156                        </div>
 157                        <div class="bs-price-field bs-price-field--wide">
 158                            <label class="form-label">Автор</label>
 159                            <input name="Author" class="form-control form-control-sm" placeholder="Автор" value="@User.I
 160                            <span class="text-danger small" id="authorError"></span>
 161                        </div>
 162                        <div class="bs-price-field">
 163                            <label class="form-label" for="modalCreateDateTime">Дата создания</label>
 164                            <input name="CreateDateTime" class="form-control form-control-sm" type="datetime-local" id="
 165                            <span class="text-danger small" id="createDateTimeError"></span>
 166                        </div>
 167                        <div class="bs-price-actions">
 168                            <button type="submit" class="btn btn-success btn-sm"><i class="fas fa-plus-circle me-1"></i>
 169                            <button type="reset" class="btn btn-outline-secondary btn-sm"><i class="fas fa-times-circle 
 170                        </div>
 171                    </form>
 172                </div>
 173
 174                <!-- Блок для истории цен -->
 175                <div id="priceHistorySection" style="display: none;">
 176                    <div class="d-flex justify-content-center">
 177                        <div class="spinner-border spinner-border-sm text-primary" id="historySpinner" role="status">
 178                            <span class="visually-hidden">Загрузка истории...</span>
 179                        </div>
 180                    </div>
 181                    <div id="priceHistoryTableContainer" class="mt-2"></div>
 182                </div>
 183
 184                <!-- Блок для данных Ozon -->
 185                <div id="ozonDataSection" class="mb-4" style="display: none;">
 186                    <div id="ozonTableContainer"></div>
 187                </div>
 188            </div>
 189        </div>
 190    </div>
 191</div>
 192
 193
 194<div class="container-fluid py-3 bsx-page bsx-surface @(isSyncTab ? "bsx-page--sync" : "")">
 195    <div class="bsx-hero d-flex flex-wrap justify-content-between align-items-start gap-3">
 196        <div>
 197            <h1>
 0198                @if (isSyncTab)
 199                {
 200                    <i class="fas fa-exchange-alt me-2 text-primary"></i>@:Синхронизация 1С → Ozon
 201                }
 0202                else if (isExceptionsTab)
 203                {
 204                    <i class="fas fa-ban me-2 text-warning"></i>@:Исключения из синхронизации
 205                }
 0206                else if (isClearHistoryTab)
 207                {
 208                    <i class="fas fa-eraser me-2 text-danger"></i>@:Обнуление остатков Ozon
 209                }
 0210                else if (isPushHistoryTab)
 211                {
 212                    <i class="fas fa-cloud-upload-alt me-2 text-success"></i>@:Накат остатков в Ozon
 213                }
 214                else
 215                {
 216                    <i class="fas fa-warehouse me-2 text-primary"></i>@:Остатки 1С
 217                }
 218            </h1>
 219            <div class="subtitle">
 0220                @if (isSyncTab)
 221                {
 222                    @:Отчёты, накат и очистка остатков на маркетплейсе
 223                }
 0224                else if (isExceptionsTab)
 225                {
 226                    @:Артикулы, правила цен и остатков — управление исключениями
 227                }
 0228                else if (isClearHistoryTab)
 229                {
 230                    @:Запуски обнуления остатков Ozon — ручные и по расписанию
 231                }
 0232                else if (isPushHistoryTab)
 233                {
 234                    @:Запуски наката остатков в Ozon — ручные и по расписанию
 235                }
 236                else
 237                {
 238                    @:Сравнение snapshot WarehouseStock с остатками Ozon
 239                }
 240            </div>
 241            <div class="bsx-quick-links">
 242                <a asp-area="Studio2" asp-controller="WarehouseMapping" asp-action="Index">
 243                    <i class="fas fa-link"></i>Связи складов Ozon ↔ 1С
 244                </a>
 245                <a asp-area="Studio2" asp-controller="Warehouse" asp-action="Index">
 246                    <i class="fas fa-warehouse"></i>Склады
 247                </a>
 248                <a asp-area="Studio2" asp-controller="OzonClients" asp-action="Index">
 249                    <i class="fas fa-store"></i>Клиенты Ozon
 250                </a>
 251                <a asp-area="Studio2" asp-controller="Job" asp-action="Index">
 252                    <i class="fas fa-tasks"></i>Фоновые задания
 253                </a>
 254            </div>
 255        </div>
 0256        @if (isStocksTab)
 257        {
 258            <div class="bsx-stat">
 259                <div class="label">Найдено</div>
 260                <div class="value" id="bsxStocksTotal">…</div>
 261            </div>
 262        }
 263    </div>
 264
 265    <ul class="nav bsx-tabs" id="bitrixStockPageTabs" role="tablist">
 266        <li class="nav-item" role="presentation">
 267            <a class="nav-link @(isSyncTab ? "active" : "")" href="@BuildSyncTabUrl()">
 268                <i class="fas fa-exchange-alt me-1"></i>Синхронизация
 269            </a>
 270        </li>
 271        <li class="nav-item" role="presentation">
 272            <a class="nav-link @(isStocksTab ? "active" : "")" href="@BuildStocksTabUrl()">
 273                <i class="fas fa-warehouse me-1"></i>Остатки (WarehouseStock)
 274            </a>
 275        </li>
 276        <li class="nav-item" role="presentation">
 277            <a class="nav-link @(isExceptionsTab ? "active" : "")" href="@BuildExceptionsTabUrl()">
 278                <i class="fas fa-ban me-1"></i>Исключения
 279            </a>
 280        </li>
 281        <li class="nav-item" role="presentation">
 282            <a class="nav-link @(isClearHistoryTab ? "active" : "")" href="@BuildClearHistoryTabUrl()"
 283               title="История запусков обнуления остатков Ozon">
 284                <i class="fas fa-eraser me-1"></i>Обнуление
 285                <span class="bsx-tab-badge badge rounded-pill" data-job-badge="clear"></span>
 286            </a>
 287        </li>
 288        <li class="nav-item" role="presentation">
 289            <a class="nav-link @(isPushHistoryTab ? "active" : "")" href="@BuildPushHistoryTabUrl()"
 290               title="История запусков наката остатков в Ozon">
 291                <i class="fas fa-cloud-upload-alt me-1"></i>Накат
 292                <span class="bsx-tab-badge badge rounded-pill" data-job-badge="push"></span>
 293            </a>
 294        </li>
 295        <li class="nav-item" role="presentation">
 296            <a class="nav-link" href="@BuildJobLogsUrl()" title="Журнал фоновых транзакций, интеграций и диагностики">
 297                <i class="fas fa-clipboard-list me-1"></i>Журнал задач
 298            </a>
 299        </li>
 300    </ul>
 301
 0302    @await Html.PartialAsync("_BitrixStockJobProgressModals")
 0303    @await Html.PartialAsync("_BitrixStockFilterCapture")
 304
 0305    @if (isSyncTab)
 306    {
 0307        @await Html.PartialAsync("_SyncReportTab", Model)
 308    }
 309
 0310    @if (isExceptionsTab)
 311    {
 0312        @await Html.PartialAsync("_ExceptionsTab", Model)
 313    }
 314
 0315    @if (isClearHistoryTab || isPushHistoryTab)
 316    {
 0317        @if (isClearHistoryTab)
 318        {
 0319            @await Html.PartialAsync("_BitrixClearHistoryTab")
 320        }
 321        else
 322        {
 0323            @await Html.PartialAsync("_BitrixPushHistoryTab")
 324        }
 325        <script>
 326            window.bitrixJobHistoryConfig = {
 0327                clearJobsListUrl: '@Url.Action("GetClearJobs", "BitrixStock", new { area = "Studio2" })',
 0328                pushJobsListUrl: '@Url.Action("GetPushJobs", "BitrixStock", new { area = "Studio2" })'
 329            };
 0330            window.jobLogsBadgeChannel = '@(isClearHistoryTab ? "clear" : "push")';
 331        </script>
 332        <script src="~/js/Studio2/BitrixStock/bitrix-job-history.js" asp-append-version="true"></script>
 333    }
 334
 0335    @if (isStocksTab)
 336    {
 337    <div class="alert alert-info py-2 mb-3">
 338        Источник остатков на этой вкладке: <strong>Studio2/WarehouseStock</strong> (синхронизированный snapshot), а не п
 339    </div>
 340    @* Отчёт авто-синка связок Ozon больше не рисуется здесь: строки грузит грид (stocks-grid), авто-push отключён. *@
 0341    @if (ViewData["PerformanceNotice"] is string performanceNotice && !string.IsNullOrWhiteSpace(performanceNotice))
 342    {
 343        <div class="alert alert-warning py-2 mb-3">
 0344            @performanceNotice
 345        </div>
 346    }
 347    <div class="alert alert-warning py-2 mb-3" id="bsxStocksNotice" hidden></div>
 348    <script>
 349        Object.assign(window.bsxGridConfig, {
 0350            stocksUrl: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Url.Action("GetStocksGrid", "BitrixStock", ne
 0351            stocksActiveStores: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.ActiveStores))
 352        });
 353    </script>
 354    <script src="~/js/Studio2/BitrixStock/SyncGrid/bsx-stocks.js" asp-append-version="true"></script>
 355    <div class="bsx-card" id="bitrixStocksCard">
 356        <div class="bsx-card-header bsx-grid-toolbar">
 357            <div class="fw-semibold"><i class="fas fa-table me-1 text-primary"></i>Остатки WarehouseStock</div>
 358            <div class="bsx-grid-search bsx-grid-search--wide">
 359                <i class="fas fa-search"></i>
 360                <input type="search" id="bsxStocksSearch" placeholder="Артикул (Enter — по всему snapshot), Ctrl+F" auto
 361            </div>
 362            <div class="bsx-grid-toolbar-end">
 363                <span class="bsx-grid-count" id="bsxStocksCount"></span>
 364                <button type="button" class="btn btn-sm btn-outline-secondary" id="bsxStocksRefreshBtn" title="Перечитат
 365                <button type="button" class="btn btn-sm btn-outline-secondary" id="bsxStocksColumnsBtn" data-bsx-popup-a
 366            </div>
 367        </div>
 368        <div class="card-body p-2">
 369            @* Форма — носитель параметров RemainingStockFilter (как у старой страницы: ссылки, сохранённые фильтры).
 370               Фильтры колонок и сортировка грида пишутся в query, панель ниже — прежние флаги и массовые действия. *@
 371            <form method="get" id="filterForm" class="bsx-stocks-toolbar">
 372        <div class="row mt-2 g-2 mb-3">
 373            <!-- Чекбоксы Ozon -->
 374            <div class="col-auto">
 375                <div class="d-flex flex-column border rounded p-2 bg-light">
 376                    <div class="form-check">
 377                        <input type="checkbox" class="form-check-input" id="LoadOzonWarehouses"
 378                               name="LoadOzonWarehouses"
 379                               value="true"
 0380                               @(Model.Filter.LoadOzonWarehouses ? "checked" : "")/>
 381                        <label class="form-check-label small" for="LoadOzonWarehouses">
 382                            Склады Ozon
 383                        </label>
 384                    </div>
 385                    <div class="form-check mt-1">
 386                        <input type="checkbox" class="form-check-input" id="DiffOzonWarehouses"
 387                               name="DiffOzonWarehouses"
 388                               value="true"
 0389                               @(Model.Filter.DiffOzonWarehouses ? "checked" : "")/>
 390                        <label class="form-check-label small" for="DiffOzonWarehouses">
 391                            Разница остатков
 392                        </label>
 393                    </div>
 394                </div>
 395            </div>
 396
 397            <div class="col-auto">
 398                <div class="d-flex flex-column border rounded p-2 bg-light">
 399                    <div class="form-check">
 400                        <input type="checkbox" class="form-check-input" id="LoadOzonPriceIndexes"
 401                               name="LoadOzonPriceIndexes"
 402                               value="true"
 0403                               @(Model.Filter.LoadOzonPriceIndexes ? "checked" : "")/>
 404                        <label class="form-check-label small" for="LoadOzonPriceIndexes">
 405                            Индексы цен Ozon
 406                        </label>
 407                    </div>
 408                    <div class="form-check mt-1">
 409                        <input type="checkbox" class="form-check-input" id="RedOzonPriceIndexes"
 410                               name="RedOzonPriceIndexes"
 411                               value="true"
 0412                               @(Model.Filter.RedOzonPriceIndexes ? "checked" : "")/>
 413                        <label class="form-check-label small" for="RedOzonPriceIndexes">
 414                            Только невыгодные
 415                        </label>
 416                    </div>
 417                </div>
 418            </div>
 419
 420            <!-- Клиент Ozon -->
 421            <div class="col-auto">
 422                <div class="border rounded p-2 bg-light">
 423                    <label for="SelectedClientId" class="form-label small mb-1">Клиент Ozon</label>
 424                    <select name="SelectedClientId" class="form-select form-select-sm"
 425                            style="width: 180px;" data-filter-col="client">
 426                        <option value="">-- все клиенты --</option>
 0427                        @foreach (var client in Model.OzonClients)
 428                        {
 0429                            <option value="@client.Id"
 430                                    selected="@(Model.SelectedClient?.Id == client.Id ? "selected" : null)">
 0431                                @client.Name
 432                            </option>
 433                        }
 434                    </select>
 435                </div>
 436            </div>
 437
 438            <!-- Массовые действия -->
 439            <div class="col-auto" id="refreshSelectedContainer" style="display: none;">
 440                <div class="border rounded p-2 bg-light">
 441                    <button type="button" class="btn btn-sm btn-primary" id="refreshSelectedBtn">
 442                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 443                             class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16">
 444                            <path fill-rule="evenodd"
 445                                  d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
 446                            <path
 447                                d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 
 448                        </svg>
 449                        Обновить (<span id="refreshSelectedCount">0</span>)
 450                    </button>
 451                </div>
 452            </div>
 453
 454            <div class="col-auto" id="clearSelectedContainer" style="display: none;">
 455                <div class="border rounded p-2 bg-light">
 456                    <button type="button" class="btn btn-sm btn-danger" id="clearSelectedBtn">
 457                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 458                             class="bi bi-dash-circle me-1" viewBox="0 0 16 16">
 459                            <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
 460                            <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8"/>
 461                        </svg>
 462                        Обнулить (<span id="clearSelectedCount">0</span>)
 463                    </button>
 464                </div>
 465            </div>
 466
 467            <div class="col-auto" id="archiveSelectedContainer" style="display: none;">
 468                <div class="border rounded p-2 bg-light">
 469                    <button type="button" class="btn btn-sm btn-secondary" id="archiveSelectedBtn"
 470                            title="Отправить выбранные товары в архив Ozon (не заархивированные)">
 471                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 472                             class="bi bi-archive me-1" viewBox="0 0 16 16">
 473                            <path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.
 474                            <path d="M3 7.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5M3.854 1.146a.5.5 0 0 1
 475                        </svg>
 476                        В архив (<span id="archiveSelectedCount">0</span>)
 477                    </button>
 478                </div>
 479            </div>
 480
 481            <div class="col-auto" id="unarchiveSelectedContainer" style="display: none;">
 482                <div class="border rounded p-2 bg-light">
 483                    <button type="button" class="btn btn-sm btn-success" id="unarchiveSelectedBtn"
 484                            title="Восстановить выбранные товары из архива Ozon">
 485                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 486                             class="bi bi-box-arrow-up me-1" viewBox="0 0 16 16">
 487                            <path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.772 0L3.732 7.5H6.5a.5.5 0 0 0 0 1H2a.5.
 488                            <path fill-rule="evenodd" d="M1.5 8.5a.5.5 0 0 1 .5-.5h12a.5.5 0 0 1 0 1H14v5.5a.5.5 0 0 1-.
 489                        </svg>
 490                        Из архива (<span id="unarchiveSelectedCount">0</span>)
 491                    </button>
 492                </div>
 493            </div>
 494
 495            <!-- Кнопка сброса -->
 496            <div class="col-auto">
 497                <div class="border rounded p-2 bg-light">
 498                    <button type="button" class="btn btn-sm btn-outline-secondary" id="resetFiltersBtn"
 499                            title="Сбросить все фильтры"
 500                            onclick="window.location.href='/BitrixStock'">
 501                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 502                             class="bi bi-arrow-counterclockwise me-1" viewBox="0 0 16 16">
 503                            <path fill-rule="evenodd"
 504                                  d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z"/>
 505                            <path
 506                                d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 
 507                        </svg>
 508                        Сбросить
 509                    </button>
 510                </div>
 511            </div>
 512
 513            <!-- Функционал сохранения фильтров -->
 514            <div class="col-auto">
 515                <div class="d-flex flex-wrap align-items-center gap-1 border rounded p-2 bg-light">
 516                    <button type="submit" class="btn btn-sm btn-primary" id="applyMainFiltersBtn"
 517                            title="Применить фильтры">
 518                        Применить
 519                    </button>
 520
 521                    <button type="button" class="btn btn-sm btn-outline-primary" id="saveFilterBtn"
 522                            title="Сохранить фильтр">
 523                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 524                             class="bi bi-bookmark-plus me-1" viewBox="0 0 16 16">
 525                            <path
 526                                d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v13.5a.5.5 0 0 1-.777.416L8 13.101l-5.223 2.815A.5.
 527                            <path
 528                                d="M8 4a.5.5 0 0 1 .5.5V6H10a.5.5 0 0 1 0 1H8.5v1.5a.5.5 0 0 1-1 0V7H6a.5.5 0 0 1 0-1h1.
 529                        </svg>
 530                        Сохранить
 531                    </button>
 532
 533                    <select class="form-select form-select-sm" id="savedFiltersSelect"
 534                            style="width: 150px;">
 535                        <option value="">Фильтры</option>
 0536                        @foreach (var savedFilter in Model.SavedFilters)
 537                        {
 0538                            <option value="@savedFilter.Id" data-filter-data="@savedFilter.FilterData">
 0539                                @savedFilter.Name
 540                            </option>
 541                        }
 542                    </select>
 543
 544                    <button type="button" class="btn btn-sm btn-outline-success" id="applyFilterBtn"
 545                            title="Применить" disabled>
 546                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 547                             class="bi bi-play-fill" viewBox="0 0 16 16">
 548                            <path
 549                                d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.
 550                        </svg>
 551                    </button>
 552
 553                    <button type="button" class="btn btn-sm btn-outline-danger" id="deleteFilterBtn"
 554                            title="Удалить" disabled>
 555                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor"
 556                             class="bi bi-trash" viewBox="0 0 16 16">
 557                            <path
 558                                d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.
 559                            <path
 560                                d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 
 561                        </svg>
 562                    </button>
 563                </div>
 564            </div>
 565        </div>
 566            </form>
 567            <div class="bsx-chips" id="bsxStocksChips" hidden></div>
 568            <div id="productsTable" class="bsx-grid bsx-grid--stocks"></div>
 569            <div class="bsx-grid-pager" id="bsxStocksPager">
 570                <button type="button" class="btn btn-sm btn-outline-secondary" data-page="first" title="Первая"><i class
 571                <button type="button" class="btn btn-sm btn-outline-secondary" data-page="prev" title="Назад"><i class="
 572                <span class="bsx-grid-count" id="bsxStocksPageInfo"></span>
 573                <button type="button" class="btn btn-sm btn-outline-secondary" data-page="next" title="Вперёд"><i class=
 574                <button type="button" class="btn btn-sm btn-outline-secondary" data-page="last" title="Последняя"><i cla
 575                <select class="form-select form-select-sm bsx-grid-select" id="bsxStocksPageSize" title="Строк на страни
 576                    <option value="100">100</option>
 577                    <option value="200">200</option>
 578                    <option value="500">500</option>
 579                </select>
 580            </div>
 581            <div class="bsx-grid-hint">Фильтры и сортировка — по всему snapshot на сервере; страница до 500 строк (по не
 582        </div>
 583    </div>
 584    }
 585
 586</div>
 587
 588<div class="modal fade" id="bitrixClearConfirmModal" tabindex="-1">
 589    <div class="modal-dialog modal-xl modal-dialog-scrollable">
 590        <div class="modal-content">
 591            <div class="modal-header">
 592                <h5 class="modal-title">Обнуление остатков Ozon</h5>
 593                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 594            </div>
 595            <div class="modal-body">
 596                <p class="text-muted mb-2">Проверьте список позиций. После подтверждения задание выполнится в фоне (Hang
 597                <div id="bitrixClearConfirmTable" class="bsx-grid bsx-grid--modal-tall"></div>
 598            </div>
 599            <div class="modal-footer">
 600                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 601                <button type="button" class="btn btn-danger" id="bitrixClearConfirmStartBtn">Продолжить</button>
 602            </div>
 603        </div>
 604    </div>
 605</div>
 606
 607<div class="modal fade" id="bitrixPushConfirmModal" tabindex="-1">
 608    <div class="modal-dialog modal-xl modal-dialog-scrollable">
 609        <div class="modal-content">
 610            <div class="modal-header">
 611                <h5 class="modal-title">Накат остатков Ozon</h5>
 612                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 613            </div>
 614            <div class="modal-body">
 615                <p class="text-muted mb-2">Проверьте список позиций. После подтверждения остатки на Ozon будут приведены
 616                <div id="bitrixPushConfirmTable" class="bsx-grid bsx-grid--modal-tall"></div>
 617            </div>
 618            <div class="modal-footer">
 619                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 620                <button type="button" class="btn btn-primary" id="bitrixPushConfirmStartBtn">Продолжить</button>
 621            </div>
 622        </div>
 623    </div>
 624</div>
 625
 626<div class="modal fade" id="bitrixPushReportModal" tabindex="-1">
 627    <div class="modal-dialog modal-xl modal-dialog-scrollable">
 628        <div class="modal-content">
 629            <div class="modal-header">
 630                <h5 class="modal-title">Отчёт наката #<span id="bitrixPushReportModalId"></span></h5>
 631                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 632            </div>
 633            <div class="modal-body">
 634                <div id="bitrixPushReportModalSummary" class="mb-2 small text-muted"></div>
 635                <div id="bitrixPushReportTable" class="bsx-grid bsx-grid--modal-tall"></div>
 636            </div>
 637        </div>
 638    </div>
 639</div>
 640
 641<div class="modal fade" id="bitrixUnarchiveConfirmModal" tabindex="-1">
 642    <div class="modal-dialog modal-xl modal-dialog-scrollable">
 643        <div class="modal-content">
 644            <div class="modal-header">
 645                <h5 class="modal-title">Восстановление товаров из архива Ozon</h5>
 646                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 647            </div>
 648            <div class="modal-body">
 649                <p class="text-muted mb-2">Только позиции в архиве. Авто-архив: не более 100 восстановлений в сутки (сбр
 650                <div id="bitrixUnarchiveConfirmTable" class="bsx-grid bsx-grid--modal-tall"></div>
 651            </div>
 652            <div class="modal-footer">
 653                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 654                <button type="button" class="btn btn-success" id="bitrixUnarchiveConfirmStartBtn">Продолжить</button>
 655            </div>
 656        </div>
 657    </div>
 658</div>
 659
 660<div class="modal fade" id="bitrixArchiveConfirmModal" tabindex="-1">
 661    <div class="modal-dialog modal-xl modal-dialog-scrollable">
 662        <div class="modal-content">
 663            <div class="modal-header">
 664                <h5 class="modal-title">Архивация товаров в Ozon</h5>
 665                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 666            </div>
 667            <div class="modal-body">
 668                <p class="text-muted mb-2">Только позиции не в архиве. После подтверждения задание выполнится в фоне (Ha
 669                <div id="bitrixArchiveConfirmTable" class="bsx-grid bsx-grid--modal-tall"></div>
 670            </div>
 671            <div class="modal-footer">
 672                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 673                <button type="button" class="btn btn-secondary" id="bitrixArchiveConfirmStartBtn">Продолжить</button>
 674            </div>
 675        </div>
 676    </div>
 677</div>
 678
 679<div class="modal fade" id="bitrixClearReportModal" tabindex="-1">
 680    <div class="modal-dialog modal-xl modal-dialog-scrollable">
 681        <div class="modal-content">
 682            <div class="modal-header">
 683                <h5 class="modal-title">Отчёт обнуления #<span id="bitrixClearReportModalId"></span></h5>
 684                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
 685            </div>
 686            <div class="modal-body">
 687                <div id="bitrixClearReportModalSummary" class="mb-2 small text-muted"></div>
 688                <div id="bitrixClearReportTable" class="bsx-grid bsx-grid--modal-tall"></div>
 689            </div>
 690        </div>
 691    </div>
 692</div>
 693
 0694@if (isStocksTab)
 695{
 696<script>
 0697    window.suppliersData = @Html.Raw(Json.Serialize(Model.Suppliers));
 0698    window.warehouseMappings = @Html.Raw(Json.Serialize(Model.WarehousesmMappings.Select(mapping => new
 0699                               {
 0700                                   mapping.Id,
 0701                                   mapping.BitrixWarehouseName,
 0702                                   mapping.OzonWarehouseName,
 0703                                   OzonClientName = mapping.OzonClient != null ? mapping.OzonClient.Name : string.Empty,
 0704                                   mapping.OzonWarehouseId
 0705                               })));
 706    window.bitrixStockRows = []; // заполняет грид (BsxStocksLegacy.onRowsRendered)
 707    const canCreateMissingOzonStocks = false;
 708    const oneCStocksBatchSize = 25;
 709    const oneCStockColor = '#7c2d12';
 710    const ozonArchiveBatchSize = 50;
 0711    const selectedOzonClientId = @(Model.Filter.SelectedClientId.HasValue ? Model.Filter.SelectedClientId.Value : 0);
 712
 713    function findOneCStockRows(article) {
 714        const normalized = (article || '').trim().toLowerCase();
 715        return Array.from(document.querySelectorAll('.bitrix-store-row[data-article]'))
 716            .filter(row => (row.getAttribute('data-article') || '').trim().toLowerCase() === normalized);
 717    }
 718
 719    function setOneCStockCellHtml(cell, html) {
 720        if (cell) {
 721            cell.innerHTML = html;
 722        }
 723    }
 724
 725    function renderOneCStockError(article, message) {
 726        const safeMessage = (message || 'Ошибка 1С').toString();
 727        findOneCStockRows(article).forEach(row => {
 728            setOneCStockCellHtml(
 729                row.querySelector('.onec-stock-qty'),
 730                `<span class="text-danger small">${safeMessage}</span>`);
 731        });
 732    }
 733
 734    function renderOneCStockItem(article, item) {
 735        const rows = findOneCStockRows(article);
 736        if (!rows.length) {
 737            return;
 738        }
 739
 740        if (!item) {
 741            rows.forEach(row => {
 742                setOneCStockCellHtml(row.querySelector('.onec-stock-qty'), '<span class="text-muted">—</span>');
 743            });
 744            return;
 745        }
 746
 747        const itemSuccess = item.success ?? item.Success;
 748        if (!itemSuccess) {
 749            const msg = item.message || item.Message || 'Номенклатура не найдена';
 750            rows.forEach(row => {
 751                setOneCStockCellHtml(
 752                    row.querySelector('.onec-stock-qty'),
 753                    `<span class="text-muted small" title="${msg}">—</span>`);
 754            });
 755            return;
 756        }
 757
 758        const stocks = Array.isArray(item.stocks)
 759            ? item.stocks
 760            : (Array.isArray(item.Stocks) ? item.Stocks : []);
 761
 762        const stockByWarehouse = new Map();
 763        stocks.forEach(stock => {
 764            const wh = normalizeWarehouseName(stock.warehouse || stock.Warehouse);
 765            if (wh) {
 766                stockByWarehouse.set(wh, stock.quantity ?? stock.Quantity);
 767            }
 768        });
 769
 770        rows.forEach(row => {
 771            const wh = normalizeWarehouseName(row.getAttribute('data-bitrix-store-name'));
 772            const cell = row.querySelector('.onec-stock-qty');
 773            if (!wh || !stockByWarehouse.has(wh)) {
 774                setOneCStockCellHtml(cell, '<span class="text-muted">—</span>');
 775                return;
 776            }
 777
 778            const qty = stockByWarehouse.get(wh);
 779            setOneCStockCellHtml(
 780                cell,
 781                `<span style="color:${oneCStockColor}; font-weight:600;">${qty}</span>`);
 782        });
 783    }
 784
 785    async function loadOneCStocksForGrid() {
 786        const articles = Array.from(document.querySelectorAll('.bitrix-stock-row[data-article]'))
 787            .map(row => (row.getAttribute('data-article') || '').trim())
 788            .filter(Boolean);
 789
 790        if (!articles.length) {
 791            return;
 792        }
 793
 794        for (let offset = 0; offset < articles.length; offset += oneCStocksBatchSize) {
 795            const batch = articles.slice(offset, offset + oneCStocksBatchSize);
 796            try {
 0797                const response = await fetch('@Url.Action("GetOneCStocksByArticles", "BitrixStock")', {
 798                    method: 'POST',
 799                    headers: { 'Content-Type': 'application/json' },
 800                    body: JSON.stringify({ Articles: batch })
 801                });
 802
 803                if (!response.ok) {
 804                    batch.forEach(article => renderOneCStockError(article, 'Ошибка запроса к 1С'));
 805                    continue;
 806                }
 807
 808                const data = await response.json();
 809                if (data.success === false && data.error) {
 810                    batch.forEach(article => renderOneCStockError(article, data.error));
 811                    continue;
 812                }
 813
 814                const items = Array.isArray(data.items) ? data.items : [];
 815                const byArticle = new Map();
 816                items.forEach(item => {
 817                    const key = (item?.article || item?.Article || '').trim().toLowerCase();
 818                    if (key) {
 819                        byArticle.set(key, item);
 820                    }
 821                });
 822
 823                batch.forEach(article => {
 824                    const item = byArticle.get(article.trim().toLowerCase());
 825                    renderOneCStockItem(article, item);
 826                });
 827            } catch (error) {
 828                batch.forEach(article => renderOneCStockError(article, error?.message || 'Сбой загрузки 1С'));
 829            }
 830        }
 831    }
 832
 833    function ozonArchivedYesLabel(manual, auto) {
 834        const suffix = manual ? ' (Вручную)' : auto ? ' (Системой)' : '';
 835        return `Да${suffix}`;
 836    }
 837
 838    function formatOzonArchiveLine(status) {
 839        if (!status) {
 840            return '';
 841        }
 842        const manual = status.isArchived === true || status.IsArchived === true;
 843        const auto = status.isAutoArchived === true || status.IsAutoArchived === true;
 844        const archived = manual || auto;
 845        const client = escapeHtml((status.clientName || status.ClientName || '').toString());
 846        const offerId = escapeHtml((status.offerId || status.OfferId || '').toString());
 847        const productId = Number(status.ozonProductId || status.OzonProductId) || 0;
 848        const yesLabel = escapeHtml(ozonArchivedYesLabel(manual, auto));
 849
 850        if (archived) {
 851            if (productId <= 0) {
 852                return `<div class="ozon-archive-line mb-1">
 853                    <span class="ozon-archive-status ozon-archive-yes-label text-danger small">${yesLabel}</span>
 854                    <small class="ozon-archive-client text-muted">${client}</small>
 855                </div>`;
 856            }
 857            return `<div class="ozon-archive-line mb-1 ozon-unarchive-pick"
 858                         data-offer-id="${offerId}"
 859                         data-ozon-product-id="${productId}"
 860                         data-ozon-client="${client}"
 861                         data-is-auto-archived="${auto ? '1' : '0'}"
 862                         data-is-manual-archived="${manual ? '1' : '0'}">
 863                <span class="ozon-archive-status ozon-unarchive-yes"
 864                      style="cursor:pointer;"
 865                      title="Клик — восстановить этот товар из архива Ozon">${yesLabel}</span>
 866                <small class="ozon-archive-client text-muted ozon-unarchive-toggle-col"
 867                       style="cursor:pointer;"
 868                       title="Клик — выделить / снять выделение для массового восстановления">${client}</small>
 869            </div>`;
 870        }
 871
 872        if (productId <= 0) {
 873            return `<div class="ozon-archive-line mb-1">
 874                <span class="ozon-archive-status ozon-archive-no-label text-success small">Нет</span>
 875                <small class="ozon-archive-client text-muted">${client}</small>
 876            </div>`;
 877        }
 878
 879        return `<div class="ozon-archive-line mb-1 ozon-archive-pick"
 880                     data-offer-id="${offerId}"
 881                     data-ozon-product-id="${productId}"
 882                     data-ozon-client="${client}">
 883            <span class="ozon-archive-status ozon-archive-no"
 884                  style="cursor:pointer;"
 885                  title="Клик — отправить этот товар в архив Ozon">Нет</span>
 886            <small class="ozon-archive-client text-muted ozon-archive-toggle-col"
 887                   style="cursor:pointer;"
 888                   title="Клик — выделить / снять выделение для массовой архивации">${client}</small>
 889        </div>`;
 890    }
 891
 892    function archiveSelectionKey(client, productId) {
 893        return `${(client || '').trim().toLowerCase()}_${productId}`;
 894    }
 895
 896    function archiveItemFromPick(pick) {
 897        const row = pick.closest('.bitrix-stock-row');
 898        return {
 899            article: row?.getAttribute('data-article') || '',
 900            offerId: pick.getAttribute('data-offer-id') || '',
 901            ozonProductId: Number(pick.getAttribute('data-ozon-product-id')),
 902            ozonClient: pick.getAttribute('data-ozon-client') || '',
 903            wasArchived: false
 904        };
 905    }
 906
 907    function toggleArchivePickSelection(pick) {
 908        if (!pick) {
 909            return;
 910        }
 911        const productId = Number(pick.getAttribute('data-ozon-product-id'));
 912        const client = pick.getAttribute('data-ozon-client') || '';
 913        const key = archiveSelectionKey(client, productId);
 914        if (selectedArchiveItems.has(key)) {
 915            selectedArchiveItems.delete(key);
 916            pick.classList.remove('selected-archive-item');
 917        } else {
 918            selectedArchiveItems.add(key);
 919            pick.classList.add('selected-archive-item');
 920        }
 921        updateSelectedUI();
 922    }
 923
 924    async function archiveSingleFromNoClick(pick) {
 925        const item = archiveItemFromPick(pick);
 926        if (!item.ozonProductId || !item.ozonClient) {
 927            showNotification('Нет product_id Ozon для архивации', 'warning');
 928            return;
 929        }
 930        const msg = item.offerId
 931            ? `Отправить в архив Ozon?\nOffer: ${item.offerId}\nКлиент: ${item.ozonClient}`
 932            : `Отправить в архив Ozon?\nКлиент: ${item.ozonClient}`;
 933        if (!(await St2Dialog.confirm(msg, { variant: 'warning', confirmText: 'В архив' }))) {
 934            return;
 935        }
 936        pendingArchiveItems = [item];
 937        await startBitrixArchiveJob({ silent: true });
 938    }
 939
 940    function renderOzonArchiveCell(offerId, statuses) {
 941        const cell = document.querySelector(`.ozon-archive-cell[data-offer-id="${CSS.escape(offerId)}"]`);
 942        if (!cell) {
 943            return;
 944        }
 945        if (!statuses || !statuses.length) {
 946            cell.innerHTML = '<span class="text-muted small">—</span>';
 947            return;
 948        }
 949        cell.innerHTML = statuses.map(formatOzonArchiveLine).join('');
 950        cell.querySelectorAll('.ozon-archive-pick').forEach(pick => {
 951            const key = archiveSelectionKey(
 952                pick.getAttribute('data-ozon-client'),
 953                pick.getAttribute('data-ozon-product-id'));
 954            if (selectedArchiveItems.has(key)) {
 955                pick.classList.add('selected-archive-item');
 956            }
 957        });
 958        cell.querySelectorAll('.ozon-unarchive-pick').forEach(pick => {
 959            const key = unarchiveSelectionKey(
 960                pick.getAttribute('data-ozon-client'),
 961                pick.getAttribute('data-ozon-product-id'));
 962            if (selectedUnarchiveItems.has(key)) {
 963                pick.classList.add('selected-unarchive-item');
 964            }
 965        });
 966        updateSelectedUI();
 967    }
 968
 969    function renderOzonArchiveError(offerId, message) {
 970        const cell = document.querySelector(`.ozon-archive-cell[data-offer-id="${CSS.escape(offerId)}"]`);
 971        if (cell) {
 972            cell.innerHTML = `<span class="text-danger small" title="${(message || 'Ошибка').toString()}">!</span>`;
 973        }
 974    }
 975
 976    async function loadOzonArchiveForGrid() {
 977        const offerIds = Array.from(document.querySelectorAll('.bitrix-stock-row[data-offer-id]'))
 978            .map(row => (row.getAttribute('data-offer-id') || '').trim())
 979            .filter(Boolean);
 980
 981        const uniqueOfferIds = [...new Set(offerIds)];
 982        if (!uniqueOfferIds.length) {
 983            return;
 984        }
 985
 986        for (let offset = 0; offset < uniqueOfferIds.length; offset += ozonArchiveBatchSize) {
 987            const batch = uniqueOfferIds.slice(offset, offset + ozonArchiveBatchSize);
 988            try {
 989                const payload = { OfferIds: batch };
 990                if (selectedOzonClientId > 0) {
 991                    payload.ClientId = selectedOzonClientId;
 992                }
 993
 0994                const response = await fetch('@Url.Action("GetOzonProductArchiveByOfferIds", "BitrixStock")', {
 995                    method: 'POST',
 996                    headers: { 'Content-Type': 'application/json' },
 997                    body: JSON.stringify(payload)
 998                });
 999
 1000                if (!response.ok) {
 1001                    batch.forEach(id => renderOzonArchiveError(id, 'Ошибка запроса к Ozon'));
 1002                    continue;
 1003                }
 1004
 1005                const data = await response.json();
 1006                if (data.success === false && data.error) {
 1007                    batch.forEach(id => renderOzonArchiveError(id, data.error));
 1008                    continue;
 1009                }
 1010
 1011                const items = Array.isArray(data.items) ? data.items : [];
 1012                const byOffer = new Map();
 1013                items.forEach(item => {
 1014                    const key = (item?.offerId || item?.OfferId || '').trim();
 1015                    if (!key) {
 1016                        return;
 1017                    }
 1018                    if (!byOffer.has(key)) {
 1019                        byOffer.set(key, []);
 1020                    }
 1021                    byOffer.get(key).push(item);
 1022                });
 1023
 1024                batch.forEach(offerId => {
 1025                    renderOzonArchiveCell(offerId, byOffer.get(offerId) || []);
 1026                });
 1027            } catch (error) {
 1028                batch.forEach(id => renderOzonArchiveError(id, error?.message || 'Сбой загрузки архива'));
 1029            }
 1030        }
 1031    }
 1032
 1033    // Грид «Остатки» (bsx-stocks.js) вызывает хук после каждой отрисовки страницы строк: ленивые остатки 1С,
 1034    // архив Ozon, кнопки массовых действий работают по DOM отрисованных строк, как раньше по <tr>.
 1035    window.BsxStocksLegacy = {
 1036        onRowsRendered(rows) {
 1037            window.bitrixStockRows = (rows || []).map(r => ({
 1038                productId: r.productId,
 1039                offerId: r.articleWithKey,
 1040                active: r.active,
 1041                bitrixStores: (r.bitrixStores || []).map(s => ({ title: s.title, amount: s.amount })),
 1042                ozonStores: (r.ozonStores || []).map(s => ({ title: s.title, storeId: s.storeId, clientName: s.clientNam
 1043            }));
 1044            loadOneCStocksForGrid().catch(error => console.warn('Ошибка загрузки остатков 1С', error));
 1045            loadOzonArchiveForGrid()
 1046                .then(() => updateSelectedUI())
 1047                .catch(error => console.warn('Ошибка загрузки архива Ozon', error));
 1048            if (typeof updateMassActionButtons === 'function') updateMassActionButtons();
 1049        },
 1050        showNotification: (message, type) => showNotification(message, type)
 1051    };
 1052
 1053    const rates = {
 1054        'RUB': 1,
 01055        'USD': parseFloat('@Model.RateUSD'.replace(',', '.')),
 01056        'EUR': parseFloat('@Model.RateEUR'.replace(',', '.')),
 01057        'BYN': parseFloat('@Model.RateBYN'.replace(',', '.'))
 1058    };
 1059    const ozonClientHistory = {};
 1060
 1061    function normalizeWarehouseName(value) {
 1062        return (value || '').toString().trim().toLowerCase();
 1063    }
 1064
 1065    function parseWarehouseId(value) {
 1066        if (value === null || value === undefined) {
 1067            return NaN;
 1068        }
 1069
 1070        const parsed = Number(value);
 1071        return Number.isFinite(parsed) ? parsed : NaN;
 1072    }
 1073
 1074    function escapeForAttrSelector(value) {
 1075        if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
 1076            return CSS.escape(value);
 1077        }
 1078
 1079        return (value || '').replace(/["\\]/g, '\\$&');
 1080    }
 1081
 1082    function findMissingOzonStocksForRow(rowData) {
 1083        if (!rowData || !Array.isArray(rowData.bitrixStores)) {
 1084            return [];
 1085        }
 1086
 1087        const allMappings = (window.warehouseMappings || []).filter(mapping => mapping);
 1088        if (!allMappings.length) {
 1089            return [];
 1090        }
 1091
 1092        const ozonStoresByClient = (rowData.ozonStores || []).reduce((acc, store) => {
 1093            const key = normalizeWarehouseName(store.clientName);
 1094            if (!key) {
 1095                return acc;
 1096            }
 1097
 1098            if (!acc[key]) {
 1099                acc[key] = [];
 1100            }
 1101            acc[key].push(store);
 1102            return acc;
 1103        }, {});
 1104        const missingCandidates = [];
 1105        const dedupeSet = new Set();
 1106
 1107        for (const bitrixStore of rowData.bitrixStores) {
 1108            const bitrixName = normalizeWarehouseName(bitrixStore.title);
 1109            if (!bitrixName) {
 1110                continue;
 1111            }
 1112
 1113            const bitrixMappings = allMappings.filter(m =>
 1114                normalizeWarehouseName(m.bitrixWarehouseName) === bitrixName);
 1115            if (!bitrixMappings.length) {
 1116                continue;
 1117            }
 1118
 1119            for (const mapping of bitrixMappings) {
 1120                const mappedWarehouseId = parseWarehouseId(mapping.ozonWarehouseId);
 1121                if (!Number.isFinite(mappedWarehouseId) || mappedWarehouseId <= 0) {
 1122                    continue;
 1123                }
 1124
 1125                const clientKey = normalizeWarehouseName(mapping.ozonClientName);
 1126                const clientOzonStores = ozonStoresByClient[clientKey] || [];
 1127                if (!clientOzonStores.length) {
 1128                    continue;
 1129                }
 1130
 1131                const hasOzonWarehouse = clientOzonStores.some(store =>
 1132                    parseWarehouseId(store.storeId) === mappedWarehouseId ||
 1133                    normalizeWarehouseName(store.title) === normalizeWarehouseName(mapping.ozonWarehouseName));
 1134                if (hasOzonWarehouse) {
 1135                    continue;
 1136                }
 1137
 1138                const clientProductId = Number(clientOzonStores.find(s => Number(s.productId) > 0)?.productId);
 1139                if (!Number.isFinite(clientProductId) || clientProductId <= 0) {
 1140                    continue;
 1141                }
 1142
 1143                const dedupeKey = `${clientKey}:${mappedWarehouseId}`;
 1144                if (dedupeSet.has(dedupeKey)) {
 1145                    continue;
 1146                }
 1147                dedupeSet.add(dedupeKey);
 1148
 1149                missingCandidates.push({
 1150                    bitrixWarehouseName: bitrixStore.title,
 1151                    ozonWarehouseName: mapping.ozonWarehouseName,
 1152                    ozonClientName: mapping.ozonClientName,
 1153                    warehouseId: mappedWarehouseId,
 1154                    stock: Math.max(0, Math.round(Number(bitrixStore.amount) || 0)),
 1155                    productId: clientProductId
 1156                });
 1157            }
 1158        }
 1159
 1160        return missingCandidates;
 1161    }
 1162
 1163    function findMissingWarehouseMappingsForRow(rowData) {
 1164        if (!rowData || !Array.isArray(rowData.bitrixStores)) {
 1165            return [];
 1166        }
 1167
 1168        const allMappings = (window.warehouseMappings || []).filter(mapping => mapping);
 1169        if (!allMappings.length) {
 1170            return rowData.bitrixStores
 1171                .filter(store => Number(store.amount) > 0)
 1172                .map(store => store.title);
 1173        }
 1174
 1175        const missingStores = [];
 1176        const seenStores = new Set();
 1177
 1178        for (const bitrixStore of rowData.bitrixStores) {
 1179            const amount = Number(bitrixStore.amount) || 0;
 1180            if (amount <= 0) {
 1181                continue;
 1182            }
 1183
 1184            const bitrixName = normalizeWarehouseName(bitrixStore.title);
 1185            if (!bitrixName || seenStores.has(bitrixName)) {
 1186                continue;
 1187            }
 1188
 1189            const hasMapping = allMappings.some(m =>
 1190                normalizeWarehouseName(m.bitrixWarehouseName) === bitrixName);
 1191            if (!hasMapping) {
 1192                seenStores.add(bitrixName);
 1193                missingStores.push(bitrixStore.title);
 1194            }
 1195        }
 1196
 1197        return missingStores;
 1198    }
 1199
 1200    function renderMissingOzonCreateButtons() {
 1201        if (!canCreateMissingOzonStocks) {
 1202            return;
 1203        }
 1204
 1205        const rowByProductId = new Map((window.bitrixStockRows || []).map(row => [Number(row.productId), row]));
 1206        const rowElements = document.querySelectorAll('.bitrix-stock-row[data-product-id]');
 1207
 1208        rowElements.forEach(rowElement => {
 1209            const productId = Number(rowElement.getAttribute('data-product-id'));
 1210            const targetContainer = rowElement.querySelector('.missing-ozon-stock-actions');
 1211            if (!targetContainer || !Number.isFinite(productId)) {
 1212                return;
 1213            }
 1214
 1215            const rowData = rowByProductId.get(productId);
 1216            const missingCandidates = findMissingOzonStocksForRow(rowData);
 1217            const missingMappings = findMissingWarehouseMappingsForRow(rowData);
 1218            rowElement.querySelectorAll('.bitrix-store-sync-actions').forEach(actionContainer => {
 1219                actionContainer.innerHTML = '';
 1220            });
 1221
 1222            const mappingHintsHtml = missingMappings.map(storeName =>
 1223                `<span class="missing-warehouse-mapping-hint" title="Добавьте связь в справочнике WarehouseMapping">
 1224                    Для склада ${storeName} нет WarehouseMapping
 1225                 </span>`
 1226            ).join('');
 1227
 1228            if (!missingCandidates.length || !rowData?.offerId) {
 1229                targetContainer.innerHTML = mappingHintsHtml;
 1230                return;
 1231            }
 1232
 1233            missingCandidates.forEach(candidate => {
 1234                const selector = `.bitrix-store-row[data-bitrix-store-name="${escapeForAttrSelector(candidate.bitrixWare
 1235                const actionContainer = rowElement.querySelector(selector);
 1236                if (!actionContainer) {
 1237                    return;
 1238                }
 1239
 1240                const candidatePayload = encodeURIComponent(JSON.stringify(candidate));
 1241                actionContainer.insertAdjacentHTML('beforeend', `
 1242                    <span class="sync-action-chip"
 1243                          title="Синхронизировать для клиента ${candidate.ozonClientName}: ${candidate.bitrixWarehouseNa
 1244                        <span class="sync-client-label">${candidate.ozonClientName}</span>
 1245                        <button type="button"
 1246                                class="btn btn-sm btn-link sync-missing-ozon-stock"
 1247                                data-product-id="${productId}"
 1248                                data-offer-id="${rowData.offerId}"
 1249                                data-ozon-product-id="${candidate.productId}"
 1250                                data-client="${candidate.ozonClientName}"
 1251                                data-stock="${candidate.stock}"
 1252                                data-warehouse-id="${candidate.warehouseId}"
 1253                                data-ozon-warehouse-name="${candidate.ozonWarehouseName ?? ''}"
 1254                                data-bitrix-store-name="${candidate.bitrixWarehouseName}"
 1255                                data-candidate="${candidatePayload}"
 1256                                title="Синхронизировать клиента ${candidate.ozonClientName}">
 1257                            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi
 1258                                <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 
 1259                                <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.
 1260                            </svg>
 1261                        </button>
 1262                    </span>
 1263                `);
 1264            });
 1265
 1266            const groupedByBitrixStore = missingCandidates.reduce((acc, candidate) => {
 1267                const key = candidate.bitrixWarehouseName;
 1268                if (!acc[key]) {
 1269                    acc[key] = [];
 1270                }
 1271                acc[key].push(candidate);
 1272                return acc;
 1273            }, {});
 1274
 1275            Object.entries(groupedByBitrixStore).forEach(([bitrixStoreName, candidates]) => {
 1276                if (candidates.length < 2) {
 1277                    return;
 1278                }
 1279
 1280                const selector = `.bitrix-store-row[data-bitrix-store-name="${escapeForAttrSelector(bitrixStoreName)}"] 
 1281                const actionContainer = rowElement.querySelector(selector);
 1282                if (!actionContainer) {
 1283                    return;
 1284                }
 1285
 1286                const allCandidatesPayload = encodeURIComponent(JSON.stringify(candidates));
 1287                actionContainer.insertAdjacentHTML('beforeend', `
 1288                    <span class="sync-action-chip sync-action-chip-all"
 1289                          title="Синхронизировать сразу все клиенты для склада ${bitrixStoreName}">
 1290                        Все (${candidates.length})
 1291                        <button type="button"
 1292                                class="btn btn-sm btn-link sync-all-missing-ozon-stock"
 1293                                data-product-id="${productId}"
 1294                                data-offer-id="${rowData.offerId}"
 1295                                data-bitrix-store-name="${bitrixStoreName}"
 1296                                data-all-candidates="${allCandidatesPayload}"
 1297                                title="Синхронизировать все маппинги для ${bitrixStoreName} (${candidates.length})">
 1298                            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi
 1299                                <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 
 1300                                <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.
 1301                            </svg>
 1302                        </button>
 1303                    </span>
 1304                `);
 1305            });
 1306
 1307            targetContainer.innerHTML = mappingHintsHtml;
 1308        });
 1309    }
 1310
 1311    function appendCreatedOzonStoreRow(productId, payload) {
 1312        const rowElement = document.querySelector(`.bitrix-stock-row[data-product-id="${productId}"]`);
 1313        if (!rowElement) {
 1314            return;
 1315        }
 1316
 1317        const ozonCell = rowElement.children[3];
 1318        if (!ozonCell) {
 1319            return;
 1320        }
 1321
 1322        let container = ozonCell.querySelector('.ozon-stores-container');
 1323        if (!container) {
 1324            ozonCell.innerHTML = `<div class="mb-0 ozon-stores-container" data-product-id="${productId}"></div>`;
 1325            container = ozonCell.querySelector('.ozon-stores-container');
 1326        }
 1327
 1328        const existingRow = container.querySelector(`.created-ozon-store-row[data-client="${payload.clientName}"][data-w
 1329        if (existingRow) {
 1330            const amountCell = existingRow.querySelector('.created-ozon-amount');
 1331            if (amountCell) {
 1332                amountCell.textContent = payload.stock;
 1333            }
 1334            return;
 1335        }
 1336
 1337        const newRow = document.createElement('div');
 1338        newRow.className = 'row align-items-center created-ozon-store-row';
 1339        newRow.setAttribute('data-client', payload.clientName);
 1340        newRow.setAttribute('data-warehouse-id', payload.warehouseId);
 1341        newRow.innerHTML = `
 1342            <div class="col-4 d-flex align-items-center">
 1343                <span style="color:#2563eb; font-weight: 600;">${payload.ozonWarehouseName || payload.bitrixStoreName}</
 1344            </div>
 1345            <div class="col-4 text-end created-ozon-amount"
 1346                 style="color:#2563eb; font-weight: 600;"
 1347                 data-client="${payload.clientName}"
 1348                 data-offer-id="${payload.offerId}"
 1349                 data-product-id="${payload.ozonProductId}"
 1350                 data-warehouse-id="${payload.warehouseId}">
 1351                ${payload.stock}
 1352            </div>
 1353            <div class="col-4">
 1354                <small class="text-muted">${payload.clientName}</small>
 1355            </div>
 1356        `;
 1357        container.prepend(newRow);
 1358    }
 1359
 1360
 1361    function handleEnter(event) {
 1362        if (event.key === 'Enter') {
 1363            event.preventDefault();
 1364            showLoadingSpinner()
 1365            document.getElementById('filterForm').submit();
 1366        }
 1367    }
 1368
 1369    document.addEventListener('click', async function (event) {
 1370        const clickedSingleChip = event.target.closest('.sync-action-chip:not(.sync-action-chip-all)');
 1371        const clickedAllChip = event.target.closest('.sync-action-chip-all');
 1372        const createButton = event.target.closest('.sync-missing-ozon-stock') || clickedSingleChip?.querySelector('.sync
 1373        const syncAllButton = event.target.closest('.sync-all-missing-ozon-stock') || clickedAllChip?.querySelector('.sy
 1374
 1375        if (!createButton && !syncAllButton) {
 1376            return;
 1377        }
 1378
 1379        event.preventDefault();
 1380
 1381        if (syncAllButton) {
 1382            if (syncAllButton.disabled) {
 1383                return;
 1384            }
 1385
 1386            const productId = Number(syncAllButton.getAttribute('data-product-id'));
 1387            const bitrixStoreName = syncAllButton.getAttribute('data-bitrix-store-name');
 1388            const rawCandidates = syncAllButton.getAttribute('data-all-candidates');
 1389            let candidates = [];
 1390            try {
 1391                candidates = JSON.parse(decodeURIComponent(rawCandidates || '[]'));
 1392            } catch {
 1393                showNotification('Не удалось прочитать список складов для массовой синхронизации', 'danger');
 1394                return;
 1395            }
 1396
 1397            if (!candidates.length) {
 1398                showNotification('Нет складов для массовой синхронизации', 'warning');
 1399                return;
 1400            }
 1401
 1402            syncAllButton.disabled = true;
 1403            const previousHtml = syncAllButton.innerHTML;
 1404            syncAllButton.innerHTML = '...';
 1405            let successCount = 0;
 1406            const failedClients = [];
 1407
 1408            for (const candidate of candidates) {
 1409                const requestPayload = {
 1410                    OzonClient: candidate.ozonClientName,
 1411                    OfferId: syncAllButton.getAttribute('data-offer-id'),
 1412                    ProductId: Number(candidate.productId),
 1413                    Stock: Number(candidate.stock),
 1414                    WarehouseId: Number(candidate.warehouseId)
 1415                };
 1416
 1417                const ok = await updateOzonProductStock(requestPayload);
 1418                if (ok) {
 1419                    successCount += 1;
 1420                    appendCreatedOzonStoreRow(productId, {
 1421                        clientName: candidate.ozonClientName,
 1422                        offerId: requestPayload.OfferId,
 1423                        ozonProductId: requestPayload.ProductId,
 1424                        warehouseId: requestPayload.WarehouseId,
 1425                        stock: requestPayload.Stock,
 1426                        bitrixStoreName: candidate.bitrixWarehouseName,
 1427                        ozonWarehouseName: candidate.ozonWarehouseName
 1428                    });
 1429                } else {
 1430                    failedClients.push(candidate.ozonClientName);
 1431                }
 1432            }
 1433
 1434            if (successCount > 0 && failedClients.length === 0) {
 1435                showNotification(`Синхронизированы все маппинги (${successCount}) для склада ${bitrixStoreName}`, 'succe
 1436                const actionsContainer = syncAllButton.closest('.bitrix-store-sync-actions');
 1437                actionsContainer?.querySelectorAll('.sync-action-chip').forEach(chip => chip.remove());
 1438            } else if (successCount > 0) {
 1439                showNotification(`Частично синхронизировано: ${successCount}/${candidates.length}. Ошибки: ${failedClien
 1440                syncAllButton.disabled = false;
 1441                syncAllButton.innerHTML = previousHtml;
 1442            } else {
 1443                showNotification(`Не удалось синхронизировать склад ${bitrixStoreName} ни для одного клиента`, 'danger')
 1444                syncAllButton.disabled = false;
 1445                syncAllButton.innerHTML = previousHtml;
 1446            }
 1447            return;
 1448        }
 1449
 1450        if (createButton.disabled) {
 1451            return;
 1452        }
 1453
 1454        const productId = Number(createButton.getAttribute('data-product-id'));
 1455        const ozonProductId = Number(createButton.getAttribute('data-ozon-product-id'));
 1456        const warehouseId = Number(createButton.getAttribute('data-warehouse-id'));
 1457        const stock = Number(createButton.getAttribute('data-stock'));
 1458        const offerId = createButton.getAttribute('data-offer-id');
 1459        const clientName = createButton.getAttribute('data-client');
 1460        const bitrixStoreName = createButton.getAttribute('data-bitrix-store-name');
 1461        const ozonWarehouseName = createButton.getAttribute('data-ozon-warehouse-name');
 1462
 1463        if (!offerId || !Number.isFinite(productId) || productId <= 0) {
 1464            showNotification('Нельзя создать остаток: некорректный товар (offer_id/bitrix product_id)', 'danger');
 1465            return;
 1466        }
 1467
 1468        if (!clientName) {
 1469            showNotification('Нельзя создать остаток: не определен клиент Ozon', 'danger');
 1470            return;
 1471        }
 1472
 1473        if (!Number.isFinite(ozonProductId) || ozonProductId <= 0) {
 1474            showNotification('Нельзя создать остаток: не определен product_id в Ozon для клиента', 'danger');
 1475            return;
 1476        }
 1477
 1478        if (!Number.isFinite(warehouseId) || warehouseId <= 0) {
 1479            showNotification('Нельзя создать остаток: некорректный warehouse_id в связи складов', 'danger');
 1480            return;
 1481        }
 1482
 1483        if (!Number.isFinite(stock) || stock < 0) {
 1484            showNotification('Нельзя создать остаток: некорректное количество', 'danger');
 1485            return;
 1486        }
 1487
 1488        const originalLabel = createButton.innerHTML;
 1489        createButton.disabled = true;
 1490        createButton.innerHTML = 'Создание...';
 1491
 1492        const isSuccess = await updateOzonProductStock({
 1493            OzonClient: clientName,
 1494            OfferId: offerId,
 1495            ProductId: ozonProductId,
 1496            Stock: stock,
 1497            WarehouseId: warehouseId
 1498        });
 1499
 1500        if (isSuccess) {
 1501            appendCreatedOzonStoreRow(productId, {
 1502                clientName,
 1503                offerId,
 1504                ozonProductId,
 1505                warehouseId,
 1506                stock,
 1507                bitrixStoreName,
 1508                ozonWarehouseName
 1509            });
 1510            createButton.closest('.sync-action-chip')?.remove();
 1511            showNotification(`Остаток синхронизирован: ${bitrixStoreName} -> ${ozonWarehouseName || 'Ozon склад'}`, 'suc
 1512            return;
 1513        }
 1514
 1515        createButton.disabled = false;
 1516        createButton.innerHTML = originalLabel;
 1517    });
 1518
 1519
 1520    function normalizeOfferId(...values) {
 1521        for (const value of values) {
 1522            if (value === null || value === undefined) continue;
 1523            const normalized = String(value).trim();
 1524            if (normalized.length > 0) return normalized;
 1525        }
 1526        return "";
 1527    }
 1528
 1529    function getBitrixStoreNamesForOzonFetch(explicitStoreNames) {
 1530        if (Array.isArray(explicitStoreNames) && explicitStoreNames.length > 0) {
 1531            return explicitStoreNames.map(s => String(s).trim()).filter(Boolean);
 1532        }
 1533
 1534        const fromChecks = Array.from(document.querySelectorAll('input[name="SelectedStoreTitles"]:checked'))
 1535            .map(cb => cb.value.trim())
 1536            .filter(Boolean);
 1537        if (fromChecks.length > 0) {
 1538            return fromChecks;
 1539        }
 1540
 1541        return [...new Set((window.warehouseMappings || [])
 1542            .map(m => m.bitrixWarehouseName)
 1543            .filter(Boolean)
 1544            .map(s => String(s).trim()))];
 1545    }
 1546
 1547    async function ensureWarehouseMappingsForClient(clientName, bitrixStoreNames) {
 1548        const names = getBitrixStoreNamesForOzonFetch(bitrixStoreNames);
 1549        if (!clientName || names.length === 0) {
 1550            return [];
 1551        }
 1552
 1553        try {
 1554            const response = await fetch('/BitrixStock/EnsureWarehouseMappings', {
 1555                method: 'POST',
 1556                headers: {'Content-Type': 'application/json'},
 1557                body: JSON.stringify({clientName, bitrixWarehouseNames: names})
 1558            });
 1559            if (!response.ok) {
 1560                return [];
 1561            }
 1562
 1563            const data = await response.json();
 1564            const created = data.created || [];
 1565            window.warehouseMappings = window.warehouseMappings || [];
 1566            created.forEach(item => {
 1567                const exists = window.warehouseMappings.some(m =>
 1568                    normalizeWarehouseName(m.bitrixWarehouseName) === normalizeWarehouseName(item.bitrixWarehouseName) &
 1569                    normalizeWarehouseName(m.ozonClientName) === normalizeWarehouseName(item.ozonClientName));
 1570                if (!exists) {
 1571                    window.warehouseMappings.push({
 1572                        Id: 0,
 1573                        bitrixWarehouseName: item.bitrixWarehouseName,
 1574                        ozonWarehouseName: item.ozonWarehouseName,
 1575                        ozonClientName: item.ozonClientName,
 1576                        ozonWarehouseId: item.ozonWarehouseId
 1577                    });
 1578                }
 1579            });
 1580            if (created.length > 0) {
 1581                renderMissingOzonCreateButtons();
 1582            }
 1583            return created;
 1584        } catch (error) {
 1585            console.warn('EnsureWarehouseMappings failed', error);
 1586            return [];
 1587        }
 1588    }
 1589
 1590    function enrichOzonDataWithWarehouseConnections(ozonData) {
 1591        if (!Array.isArray(ozonData)) {
 1592            return ozonData;
 1593        }
 1594
 1595        ozonData.forEach(clientData => {
 1596            if (!clientData?.WarehouseInfo?.result) {
 1597                return;
 1598            }
 1599
 1600            clientData.WarehouseInfo.result = clientData.WarehouseInfo.result.filter(warehouse =>
 1601                checkWarehouseConnection(warehouse.warehouse_name, null, clientData.Client) &&
 1602                Number(warehouse.present) > 0);
 1603
 1604            clientData.WarehouseInfo.result.forEach(warehouse => {
 1605                warehouse.hasBitrixConnection = true;
 1606            });
 1607        });
 1608
 1609        return ozonData;
 1610    }
 1611
 1612    function applyOzonTableResponse(ozonResponse) {
 1613        if (!ozonResponse || ozonResponse.length === 0) {
 1614            console.warn('Данные от Ozon не получены');
 1615            $("#ozonTableContainer").empty();
 1616            return;
 1617        }
 1618
 1619        enrichOzonDataWithWarehouseConnections(ozonResponse);
 1620        const ozonHtml = renderOzonTable(ozonResponse);
 1621        if (ozonHtml) {
 1622            $("#ozonDataSection").show();
 1623            $("#ozonTableContainer").html(ozonHtml);
 1624            mountOzonDataGrid();
 1625            const inputVal = parseFloat($('input[name="PriceValue"]').val()) || 0;
 1626            recalcRanges(inputVal);
 1627        }
 1628    }
 1629
 1630    async function reloadOzonPanel(offerId, storeNames, clientName) {
 1631        const normalizedOfferId = normalizeOfferId(offerId);
 1632        if (!normalizedOfferId) {
 1633            return null;
 1634        }
 1635
 1636        const names = getBitrixStoreNamesForOzonFetch(storeNames);
 1637        if (clientName) {
 1638            await ensureWarehouseMappingsForClient(clientName, names);
 1639        } else {
 1640            const clients = [...new Set((window.warehouseMappings || [])
 1641                .map(m => m.ozonClientName)
 1642                .filter(Boolean))];
 1643            for (const c of clients) {
 1644                await ensureWarehouseMappingsForClient(c, names);
 1645            }
 1646        }
 1647
 1648        const ozonResponse = await fetchOzonData(normalizedOfferId, names);
 1649        applyOzonTableResponse(ozonResponse);
 1650        return ozonResponse;
 1651    }
 1652
 1653    function fetchOzonData(article, storeNames) {
 1654        const offerId = normalizeOfferId(article);
 1655        if (!offerId) {
 1656            console.warn('Пустой offer_id для запроса Ozon');
 1657            return Promise.resolve([]);
 1658        }
 1659
 1660        return $.ajax({
 01661            url: '@Url.Action("GetOzonPrices", "BitrixStock")',
 1662            type: "GET",
 1663            data: {
 1664                article: offerId,
 1665                storeNames: getBitrixStoreNamesForOzonFetch(storeNames)
 1666            },
 1667            traditional: true // важно для корректной передачи List<string> в ASP.NET
 1668        }).catch(error => {
 1669            if (error.status === 500) {
 1670                console.error('Ошибка 500 при получении данных из Ozon:', error.responseText);
 1671            }
 1672            return null;
 1673        });
 1674    }
 1675
 1676    function GetTradesoftDataForBitrix(article, producer) {
 1677        return $.ajax({
 01678            url: '@Url.Action("GetTradesoftDataForBitrix", "Tradesoft")',
 1679            type: "GET",
 1680            data: {article: article, producer: producer}
 1681        }).catch(error => {
 1682            if (error.status === 500 || error.status === 404) {
 1683                console.error('Ошибка 500 при получении данных от Tradesoft:', error);
 1684            }
 1685            return [];
 1686        });
 1687    }
 1688
 1689    function GetAbcpDataForBitrix(article, producer) {
 1690        return $.ajax({
 01691            url: '@Url.Action("GetAbcpDataForBitrix", "Tradesoft")',
 1692            type: "GET",
 1693            data: {article: article, producer: producer}
 1694        }).catch(error => {
 1695            if (error.status === 500) {
 1696                console.error('Ошибка 500 при получении данных от Abcp:', error);
 1697            }
 1698            return [];
 1699        });
 1700    }
 1701
 1702    function GetAvdDataForBitrix(orderId) {
 1703        return $.ajax({
 01704            url: '@Url.Action("GetAvdDataForBitrix", "Tradesoft")',
 1705            type: "GET",
 1706            data: {orderId: orderId}
 1707        }).catch(error => {
 1708            if (error.status === 500 || error.status === 404) {
 1709                console.error('Ошибка 500 при получении данных от Avd:', error);
 1710            }
 1711            return [];
 1712        });
 1713    }
 1714
 1715    function GetCombinedDataForBitrix(article, producer, regionCode = 1, orderId = null) {
 1716        let deferred = $.Deferred();
 1717
 1718        $.when(
 1719            GetTradesoftDataForBitrix(article, producer),
 1720            GetAbcpDataForBitrix(article, producer),
 1721            orderId ? GetAvdDataForBitrix(orderId) : $.Deferred().resolve([])
 1722        ).done(function (tradesoftItems, abcpItems, avdItems) {
 1723            try {
 1724                let tItems = Array.isArray(tradesoftItems?.[0]) ? tradesoftItems[0] : tradesoftItems || [];
 1725                let aItems = Array.isArray(abcpItems?.[0]) ? abcpItems[0] : abcpItems || [];
 1726                let vItems = Array.isArray(avdItems?.[0]) ? avdItems[0] : avdItems || [];
 1727
 1728                deferred.resolve([...tItems, ...aItems, ...vItems]);
 1729            } catch (e) {
 1730                console.error("Ошибка при объединении данных:", e);
 1731                deferred.resolve([]);
 1732            }
 1733        }).fail(function (error1, error2, error3, error4) {
 1734            console.error("Не удалось загрузить данные от одного или нескольких источников");
 1735
 1736            let tItems = (error1 && (error1.status === 500 || error1.status === 404)) ? [] : (error1 || []);
 1737            let zItems = (error2 && (error2.status === 500 || error2.status === 404)) ? [] : (error2 || []);
 1738            let aItems = (error3 && (error3.status === 500 || error3.status === 404)) ? [] : (error3 || []);
 1739            let vItems = (error4 && (error4.status === 500 || error4.status === 404)) ? [] : (error4 || []);
 1740
 1741            if (tItems.length > 0 || zItems.length > 0 || aItems.length > 0 || vItems.length > 0) {
 1742                deferred.resolve([...tItems, ...zItems, ...aItems, ...vItems]);
 1743            } else {
 1744                deferred.reject(error1 || error2 || error3 || error4);
 1745            }
 1746        });
 1747
 1748        return deferred.promise();
 1749    }
 1750
 1751
 1752    function fetchBitrixData(orderArticle) {
 1753        return $.ajax({
 01754            url: '@Url.Action("GetBitrixData", "Bitrix")',
 1755            type: "GET",
 1756            data: {article: orderArticle}
 1757        }).catch(error => {
 1758            if (error.status === 500) {
 1759                console.error('Ошибка 500 при получении данных из Bitrix:', error.responseText);
 1760            }
 1761            return null;
 1762        });
 1763    }
 1764
 1765    // «Данные Bitrix» в модалке товара — грид AG Grid: цена/склад/остаток; клик по складу (.bitrix-stock) —
 1766    // как раньше, выбор остатка для наката на выбранный склад Ozon. Разметка — контейнер, грид монтирует mountBitrixDat
 1767    let _bitrixDataRows = [];
 1768    let _bitrixDataGridApi = null;
 1769
 1770    function renderBitrixTable(data) {
 1771        _bitrixDataRows = [];
 1772        if (!data || data.length === 0) {
 1773            return "<p class='text-muted'>Нет данных</p>";
 1774        }
 1775        data.forEach((item, gi) => {
 1776            if (item.group === "Учетная цена") {
 1777                document.querySelector('input[name="RegistrationPrice"]').value = item.price;
 1778            }
 1779            const stores = item.stores && Object.keys(item.stores).length ? Object.entries(item.stores) : [[null, null]]
 1780            stores.forEach(([name, amount], index) => _bitrixDataRows.push({
 1781                id: `${gi}#${index}`, first: index === 0, span: stores.length,
 1782                group: item.group, price: item.price, currency: item.currency, store: name, amount
 1783            }));
 1784        });
 1785        return '<strong class="bs-modal-section-title">Данные Bitrix</strong><div id="bitrixDataGrid" class="bsx-grid bs
 1786    }
 1787
 1788    function mountBitrixDataGrid() {
 1789        const el = document.getElementById('bitrixDataGrid');
 1790        if (_bitrixDataGridApi) { try { _bitrixDataGridApi.destroy(); } catch (_) { } _bitrixDataGridApi = null; }
 1791        if (!el || !window.BsxGrid) return;
 1792        _bitrixDataGridApi = window.BsxGrid.create(el, {
 1793            key: 'bitrixData',
 1794            rowData: _bitrixDataRows,
 1795            getRowId: p => p.data.id,
 1796            defaultColDef: { sortable: false, filter: false, floatingFilter: false },
 1797            gridOptions: { domLayout: 'autoHeight', suppressRowTransform: true, rowHeight: 30 },
 1798            columnDefs: [
 1799                { colId: 'group', headerName: 'Цена', width: 170, rowSpan: p => p.data?.first ? p.data.span : 1,
 1800                  cellClassRules: { 'bsx-span-cell': p => !!p.data?.first }, cellRenderer: p => p.data?.first ? escapeHt
 1801                { colId: 'price', headerName: 'Значение', width: 130, type: 'numericColumn', rowSpan: p => p.data?.first
 1802                  cellClassRules: { 'bsx-span-cell': p => !!p.data?.first },
 1803                  cellRenderer: p => p.data?.first
 1804                      ? `<span data-price-name="${escapeHtml(p.data.group)}" data-price-value="${p.data.price ?? ''}">${
 1805                { colId: 'store', headerName: 'Склад', flex: 1, minWidth: 120,
 1806                  cellRenderer: p => p.data?.store == null ? '<span class="text-muted">Нет данных</span>'
 1807                      : `<a href="#" class="bitrix-stock" data-amount="${p.data.amount}" data-name="${escapeHtml(p.data.
 1808                { colId: 'amount', field: 'amount', headerName: 'Всего', width: 100, type: 'numericColumn' },
 1809                { colId: 'currency', field: 'currency', headerName: 'Валюта', width: 90 }
 1810            ]
 1811        });
 1812    }
 1813
 1814    function getOzonHistory() {
 1815        try {
 1816            return JSON.parse(localStorage.getItem("ozonClientHistory")) || {};
 1817        } catch {
 1818            return {};
 1819        }
 1820    }
 1821
 1822    function saveOzonHistory(data) {
 1823        localStorage.setItem("ozonClientHistory", JSON.stringify(data));
 1824    }
 1825
 1826    // «Данные Ozon» в модалке товара — грид AG Grid: по клиенту три независимых списка (цены, комиссии и индексы,
 1827    // склады) рядом, клиент и валюта — объединённые ячейки. Расчёты (подсказка «Обновить цены», диапазон прибыли)
 1828    // читают модель _ozonClients, а не DOM. Разметка — контейнер, грид монтирует mountOzonDataGrid().
 1829    let _ozonRows = [];
 1830    let _ozonClients = {};
 1831    let _ozonGridApi = null;
 1832    const OZON_INDEX_TONE = { green: 'text-success', orange: 'text-warning', red: 'text-danger', gray: 'text-muted' };
 1833
 1834    function renderOzonTable(data) {
 1835        if (!data || data.length === 0) {
 1836            return "<p class='text-muted'>Нет данных Ozon</p>";
 1837        }
 1838
 1839        let history = getOzonHistory();
 1840        const refreshOfferId = normalizeOfferId(data[0]?.Article, data[0]?.Item?.offer_id);
 1841
 1842        _ozonRows = [];
 1843        _ozonClients = {};
 1844        data.forEach((entry, clientIndex) => {
 1845            const item = entry.Item || {};
 1846            const clientName = entry.Client ?? "-";
 1847            const currency = item.price?.currency_code ?? "-";
 1848            const clientId = `client-${clientIndex}`;
 1849            const article = data[0].Article;
 1850            const storageKey = `${article}_${clientId}`;
 1851            const priceValue = item.price?.price;
 1852
 1853            const currentPrice = Number(item.price?.true_price ?? 0);
 1854            const currentIndex = Number(item.price_indexes?.ozon_index_data?.price_index_value ?? 0);
 1855
 1856            let oldIndexNote = "";
 1857            if (history[storageKey]) {
 1858                const prev = history[storageKey];
 1859                if (prev.price !== currentPrice && prev.index === currentIndex) {
 1860                    oldIndexNote = `<span style="color: gray; font-size: 0.85em;">(${prev.index})</span>`;
 1861                }
 1862            }
 1863
 1864            history[storageKey] = {price: currentPrice, index: currentIndex};
 1865
 1866            // Цены с классами для раскраски
 1867            const priceTypes = [
 1868                {key: "true_price", name: "Цена с Ozon Картой", value: item.price?.customer_price ?? item.price?.true_pr
 1869                {
 1870                    key: "green_target_customer_price",
 1871                    name: "Реком. цена с картой",
 1872                    value: item.price?.green_target_customer_price ?? "-",
 1873                    nameCssClass: "text-warning",
 1874                    valueCssClass: "text-warning"
 1875                },
 1876                {
 1877                    key: "green_target_seller_price",
 1878                    name: "Реком. цена продавца",
 1879                    value: item.price?.green_target_seller_price ?? "-",
 1880                    nameCssClass: "text-warning",
 1881                    valueCssClass: "text-warning"
 1882                },
 1883                {
 1884                    key: "green_target_formula",
 1885                    name: "Как рассчитана реком. цена",
 1886                    value: item.price?.green_target_formula ?? "-",
 1887                    nameCssClass: "text-muted",
 1888                    valueCssClass: "text-muted"
 1889                },
 1890                {
 1891                    key: "green_target_candidates",
 1892                    name: "Кандидаты расчета",
 1893                    value: item.price?.green_target_candidates ?? "-",
 1894                    nameCssClass: "text-muted",
 1895                    valueCssClass: "text-muted"
 1896                },
 1897                {
 1898                    key: "customer_price_formula",
 1899                    name: "Как рассчитана цена с картой",
 1900                    value: item.price?.customer_price_formula ?? "-",
 1901                    nameCssClass: "text-muted",
 1902                    valueCssClass: "text-muted"
 1903                },
 1904                {
 1905                    key: "green_target_error",
 1906                    name: "Причина отсутствия рекомендации",
 1907                    value: item.price?.green_target_error ?? "-",
 1908                    nameCssClass: "text-danger",
 1909                    valueCssClass: "text-danger"
 1910                },
 1911                {
 1912                    key: "max_commission",
 1913                    name: "Комиссия макс.",
 1914                    value: item.price?.max_commission ?? "-",
 1915                    nameCssClass: "text-success",
 1916                    valueCssClass: "text-success"
 1917                },
 1918                {
 1919                    key: "min_commission",
 1920                    name: "Комиссия мин.",
 1921                    value: item.price?.min_commission ?? "-",
 1922                    nameCssClass: "text-danger",
 1923                    valueCssClass: "text-danger"
 1924                },
 1925                {
 1926                    key: "price_range_max",
 1927                    name: "Прибыль макс.",
 1928                    value: "-",
 1929                    nameCssClass: "text-success",
 1930                    valueCssClass: "text-success"
 1931                },
 1932                {
 1933                    key: "price_range_min",
 1934                    name: "Прибыль мин.",
 1935                    value: "-",
 1936                    nameCssClass: "text-danger",
 1937                    valueCssClass: "text-danger"
 1938                },
 1939
 1940                {key: "ozon_reward", name: "Вознаграждение Ozon", value: item.price?.ozon_reward ?? "-"},
 1941                {key: "price", name: "Цена (со скидками)", value: item.price?.price ?? "-"},
 1942                {key: "old_price", name: "Старая цена", value: item.price?.old_price ?? "-"},
 1943                {key: "marketing_price", name: "Цена с акциями Ozon", value: item.price?.marketing_price ?? "-"},
 1944                {
 1945                    key: "marketing_seller_price",
 1946                    name: "Цена с акциями продавца",
 1947                    value: item.price?.marketing_seller_price ?? "-"
 1948                },
 1949                {key: "min_price", name: "Мин. цена после скидок", value: item.price?.min_price ?? "-"},
 1950                {key: "net_price", name: "Себестоимость", value: item.price?.net_price ?? "-"},
 1951                {key: "retail_price", name: "Цена поставщика", value: item.price?.retail_price ?? "-"},
 1952                {key: "customer_discount_percent", name: "Скидка до цены с картой, %", value: item.price?.customer_disco
 1953                {key: "vat", name: "Ставка НДС", value: item.price?.vat ?? "-"},
 1954                {key: "currency_code", name: "Валюта", value: item.price?.currency_code ?? "-"},
 1955                {
 1956                    key: "auto_action_enabled",
 1957                    name: "Автоприменение акций",
 1958                    value: item.price?.auto_action_enabled ? "Да" : "Нет"
 1959                },
 1960                {
 1961                    key: "auto_add_to_ozon_actions_list_enabled",
 1962                    name: "Автодобавление в акции",
 1963                    value: item.price?.auto_add_to_ozon_actions_list_enabled ? "Да" : "Нет"
 1964                }
 1965            ];
 1966
 1967
 1968            const c = item.commissions || {};
 1969            const indx = item.price_indexes || {};
 1970            const commissionTypes = [
 1971                {
 1972                    key: "price_index",
 1973                    name: "Индекс цены",
 1974                    value: indx.color_index ?? "WITHOUT_INDEX",
 1975                    description: getColorIndexDescription(indx.color_index),
 1976                    color: getColorIndexColor(indx.color_index)
 1977                },
 1978                {
 1979                    key: "green_target_index",
 1980                    name: "Целевой индекс",
 1981                    value: item.price?.green_target_index ?? "-"
 1982                },
 1983                {
 1984                    key: "green_target_source",
 1985                    name: "Ограничивающий рынок",
 1986                    value: item.price?.green_target_source ?? "-"
 1987                },
 1988                {
 1989                    key: "green_target_source_price",
 1990                    name: "Цена ориентира",
 1991                    value: item.price?.green_target_source_price ?? "-"
 1992                },
 1993                {
 1994                    key: "green_target_source_index",
 1995                    name: "Индекс ориентира",
 1996                    value: item.price?.green_target_source_index ?? "-"
 1997                },
 1998                {
 1999                    key: "ozon_min_price",
 2000                    name: "Мин. цена у конкурентов на Ozon",
 2001                    value: indx.ozon_index_data?.min_price ?? "-"
 2002                },
 2003                {
 2004                    key: "ozon_index_value",
 2005                    name: "Индекс цены на Ozon",
 2006                    value: indx.ozon_index_data?.price_index_value ?? "-"
 2007                },
 2008
 2009                {
 2010                    key: "external_min_price",
 2011                    name: "Мин. цена у конкурентов на другой площадке",
 2012                    value: indx.external_index_data?.min_price ?? "-"
 2013                },
 2014                {
 2015                    key: "external_index_value",
 2016                    name: "Индекс цены на другой площадке",
 2017                    value: indx.external_index_data?.price_index_value ?? "-"
 2018                },
 2019
 2020                {
 2021                    key: "self_marketplaces_min_price",
 2022                    name: "Мин. цена вашего товара на других площадках",
 2023                    value: indx.self_marketplaces_index_data?.min_price ?? "-"
 2024                },
 2025                {
 2026                    key: "self_marketplaces_index_value",
 2027                    name: "Индекс цены на ваших площадках",
 2028                    value: indx.self_marketplaces_index_data?.price_index_value ?? "-"
 2029                },
 2030                {key: "ozon_reward", name: "Комиссия за продажу (FBS)", value: item.price?.ozon_reward ?? "-"},
 2031                {
 2032                    key: "sales_percent_fbs",
 2033                    name: "Процент комиссии за продажу (FBS)",
 2034                    value: c.sales_percent_fbs ?? "-"
 2035                },
 2036                {key: "acquiring", name: "Эквайринг", value: item.acquiring ? Number(item.acquiring).toFixed(2) : "-"},
 2037                {
 2038                    key: "fbs_first_mile_max",
 2039                    name: "Максимальная комиссия за обработку отправления (FBS)",
 2040                    value: c.fbs_first_mile_max_amount ?? "-"
 2041                },
 2042                {
 2043                    key: "fbs_first_mile_min",
 2044                    name: "Минимальная комиссия за обработку отправления (FBS)",
 2045                    value: c.fbs_first_mile_min_amount ?? "-"
 2046                },
 2047                {
 2048                    key: "fbs_direct_flow_trans_max",
 2049                    name: "Магистраль до (FBS)",
 2050                    value: c.fbs_direct_flow_trans_max_amount ?? "-"
 2051                },
 2052                {
 2053                    key: "fbs_deliv_to_customer",
 2054                    name: "Последняя миля (FBS)",
 2055                    value: c.fbs_deliv_to_customer_amount ?? "-"
 2056                },
 2057                {
 2058                    key: "fbs_return_flow",
 2059                    name: "Комиссия за возврат и отмену, обработка отправления (FBS)",
 2060                    value: c.fbs_return_flow_amount ?? "-"
 2061                },
 2062                {
 2063                    key: "fbs_direct_flow_trans_min",
 2064                    name: "Магистраль от (FBS)",
 2065                    value: c.fbs_direct_flow_trans_min_amount ?? "-"
 2066                },
 2067            ];
 2068
 2069            let warehouseTypes = [];
 2070            if (entry.WarehouseInfo?.result && entry.WarehouseInfo.result.length > 0) {
 2071                warehouseTypes = entry.WarehouseInfo.result
 2072                    .filter(w => w.hasBitrixConnection !== false)
 2073                    .map(w => {
 2074                        const present = w.present ?? 0;
 2075                        const reserved = w.reserved ?? 0;
 2076                        const hasConnection = w.hasBitrixConnection ?? false;
 2077                        return {
 2078                            key: w.warehouse_id ?? "",
 2079                            name: w.warehouse_name ?? "-",
 2080                            total: present,
 2081                            reserved: reserved,
 2082                            offer_id: item.offer_id,
 2083                            product_id: item.product_id,
 2084                            client: entry.Client,
 2085                            hasBitrixConnection: hasConnection
 2086                        };
 2087                    })
 2088                    .sort((a, b) => {
 2089                        const aHasStock = a.total > 0;
 2090                        const bHasStock = b.total > 0;
 2091                        const aHasConnection = a.hasBitrixConnection;
 2092                        const bHasConnection = b.hasBitrixConnection;
 2093
 2094                        // Сначала склады с наличием и связью с Bitrix
 2095                        if (aHasStock && aHasConnection && (!bHasStock || !bHasConnection)) return -1;
 2096                        if (bHasStock && bHasConnection && (!aHasStock || !aHasConnection)) return 1;
 2097
 2098                        // Затем склады с наличием
 2099                        if (aHasStock && !bHasStock) return -1;
 2100                        if (!aHasStock && bHasStock) return 1;
 2101
 2102                        // Затем склады со связью с Bitrix
 2103                        if (aHasConnection && !bHasConnection) return -1;
 2104                        if (!aHasConnection && bHasConnection) return 1;
 2105
 2106                        return a.name.localeCompare(b.name);
 2107                    });
 2108            }
 2109            const effectiveOfferId = normalizeOfferId(warehouseTypes[0]?.offer_id, item.offer_id, entry.Article, article
 2110
 2111            const maxRows = Math.max(priceTypes.length, commissionTypes.length, warehouseTypes.length);
 2112            const byKey = list => Object.fromEntries(list.filter(x => x.key).map(x => [x.key, x.value]));
 2113            _ozonClients[clientId] = { prices: byKey(priceTypes), commissions: byKey(commissionTypes), rangeMin: '-', ra
 2114
 2115            for (let i = 0; i < maxRows; i++) {
 2116                _ozonRows.push({
 2117                    id: `${clientId}#${i}`, clientId, first: i === 0, span: maxRows,
 2118                    clientName, effectiveOfferId, productId: warehouseTypes[i]?.product_id ?? warehouseTypes[0]?.product
 2119                    priceValue, currency, oldIndexNote,
 2120                    price: priceTypes[i], commission: commissionTypes[i], warehouse: warehouseTypes[i]
 2121                });
 2122            }
 2123        });
 2124        saveOzonHistory(history);
 2125        return `
 2126        <strong class="bs-modal-section-title d-inline-flex align-items-center gap-2">
 2127            Данные Ozon
 2128            <button type="button" class="btn btn-sm btn-link p-0 border-0 refreshOzon" data-article="${refreshOfferId}" 
 2129                <i class="fas fa-rotate-right text-primary"></i>
 2130            </button>
 2131        </strong>
 2132        <div id="ozonDataGrid" class="bsx-grid bsx-grid--auto"></div>`;
 2133    }
 2134
 2135    function mountOzonDataGrid() {
 2136        const el = document.getElementById('ozonDataGrid');
 2137        if (_ozonGridApi) { try { _ozonGridApi.destroy(); } catch (_) { } _ozonGridApi = null; }
 2138        if (!el || !window.BsxGrid) return;
 2139        const span = p => p.data?.first ? p.data.span : 1;
 2140        const spanCls = { 'bsx-span-cell': p => !!p.data?.first };
 2141        const attr = v => escapeHtml(v ?? '');
 2142        const calcKeys = ['green_target_formula', 'green_target_candidates', 'customer_price_formula'];
 2143        _ozonGridApi = window.BsxGrid.create(el, {
 2144            key: 'ozonData',
 2145            rowData: _ozonRows,
 2146            getRowId: p => p.data.id,
 2147            defaultColDef: { sortable: false, filter: false, floatingFilter: false, tooltipValueGetter: () => null },
 2148            gridOptions: { domLayout: 'autoHeight', suppressRowTransform: true, rowHeight: 30, suppressCellFocus: true }
 2149            columnDefs: [
 2150                { colId: 'client', headerName: 'Клиент', width: 190, rowSpan: span, cellClassRules: spanCls,
 2151                  cellClass: 'ozon-client-cell',
 2152                  // contenteditable индекс/процент внутри ячейки — клавиши не перехватывает грид.
 2153                  suppressKeyboardEvent: () => true,
 2154                  cellRenderer: p => !p.data?.first ? '' : `<div class="client-cell" data-client-id="${p.data.clientId}"
 2155                        <div class="fw-semibold">${escapeHtml(p.data.clientName)}</div>
 2156                        <a href="#" class="update_price text-decoration-none" data-client-id="${p.data.clientId}"
 2157                           data-offer-id="${attr(p.data.effectiveOfferId)}" data-product-id="${attr(p.data.productId)}"
 2158                           data-client="${attr(p.data.clientName)}" data-bs-toggle="popover" data-bs-trigger="hover focu
 2159                           data-bs-placement="right" data-bs-html="true" data-constant-index="0.90" data-percent="2">
 2160                            Обновить цены
 2161                            <small class="text-muted">(<span class="constant-index" contenteditable="true">0.90</span> /
 2162                                <span class="percent" contenteditable="true">2</span>%)</small>
 2163                        </a><br>
 2164                        <a href="#" class="set_price text-decoration-none" data-offer-id="${attr(p.data.effectiveOfferId
 2165                           data-product-id="${attr(p.data.productId)}" data-client="${attr(p.data.clientName)}"
 2166                           data-price-value="${attr(p.data.priceValue)}">Установить цену</a>
 2167                    </div>` },
 2168                { colId: 'priceName', headerName: 'Цена', width: 210, tooltipValueGetter: p => p.data?.price?.name,
 2169                  cellClass: p => ['price-name', p.data?.price?.key === 'true_price' ? 'text-warning fw-semibold' : (p.d
 2170                  valueGetter: p => p.data?.price?.name ?? '' },
 2171                { colId: 'priceValue', headerName: 'Значение', width: 150, type: 'numericColumn',
 2172                  tooltipValueGetter: p => p.data?.price?.value,
 2173                  cellClass: p => ['price-cell', p.data?.price?.key === 'true_price' ? 'text-warning fw-semibold' : (p.d
 2174                      calcKeys.includes(p.data?.price?.key) ? 'ozon-calc-cell' : ''].join(' '),
 2175                  cellRenderer: p => {
 2176                      const pr = p.data?.price;
 2177                      if (!pr) return '';
 2178                      const client = _ozonClients[p.data.clientId] || {};
 2179                      if (pr.key === 'price_range_min') return `<span class="price-range-min">${escapeHtml(client.rangeM
 2180                      if (pr.key === 'price_range_max') return `<span class="price-range-max">${escapeHtml(client.rangeM
 2181                      if (calcKeys.includes(pr.key)) return buildCalculationPreview(pr.value);
 2182                      return pr.value ? escapeHtml(formatNumber(pr.value)) : '';
 2183                  } },
 2184                { colId: 'commissionName', headerName: 'Комиссия', flex: 1, minWidth: 220, tooltipValueGetter: p => p.da
 2185                  cellClass: 'commission-name', valueGetter: p => p.data?.commission?.name ?? '' },
 2186                { colId: 'commissionValue', headerName: 'Значение', width: 150, type: 'numericColumn',
 2187                  tooltipValueGetter: p => p.data?.commission?.key === 'price_index' ? p.data.commission.description : p
 2188                  cellClass: p => {
 2189                      const c = p.data?.commission;
 2190                      if (c?.key === 'ozon_index_value' && !isNaN(+c.value)) return 'commission-cell fw-semibold ' + (+c
 2191                      return 'commission-cell';
 2192                  },
 2193                  cellRenderer: p => {
 2194                      const c = p.data?.commission;
 2195                      if (!c) return '';
 2196                      if (c.key === 'price_index' && c.color)
 2197                          return `<span class="fw-bold ${OZON_INDEX_TONE[c.color] || ''}">${escapeHtml(c.description)}</
 2198                      return `${escapeHtml(c.value ?? '')} ${c.key === 'ozon_index_value' ? (p.data.oldIndexNote || '') 
 2199                  } },
 2200                { colId: 'warehouse', headerName: 'Склад', width: 220, tooltipValueGetter: p => p.data?.warehouse?.name,
 2201                  cellRenderer: p => {
 2202                      const w = p.data?.warehouse;
 2203                      if (!w) return '';
 2204                      const ok = !!w.hasBitrixConnection;
 2205                      return `<a href="#" class="warehouse-link ${ok ? 'text-success fw-bold' : ''}" data-key="${attr(w.
 2206                          data-offer-id="${attr(w.offer_id)}" data-product-id="${attr(w.product_id)}" data-client="${att
 2207                          data-warehouse-id="${attr(w.key)}" data-warehouse-name="${attr(w.name)}" data-has-connection="
 2208                          title="${attr(w.name)}">${escapeHtml(w.name)}${ok ? ' <i class="fas fa-link ms-1 text-success"
 2209                  } },
 2210                { colId: 'total', headerName: 'Всего', width: 90, type: 'numericColumn',
 2211                  cellRenderer: p => {
 2212                      const w = p.data?.warehouse;
 2213                      if (!w) return '';
 2214                      const ok = !!w.hasBitrixConnection;
 2215                      return `<a href="#" class="warehouse-set-link ${ok ? 'text-success fw-bold' : ''}" data-key="${att
 2216                          data-offer-id="${attr(w.offer_id)}" data-product-id="${attr(w.product_id)}" data-client="${att
 2217                          data-warehouse-id="${attr(w.key)}" data-warehouse-name="${attr(w.name)}" data-total="${w.total
 2218                          data-has-connection="${ok}" title="Установить остаток">${w.total ?? ''}</a>`;
 2219                  } },
 2220                { colId: 'reserved', headerName: 'Резерв', width: 90, type: 'numericColumn',
 2221                  cellClass: p => p.data?.warehouse?.hasBitrixConnection ? 'text-success fw-bold' : '',
 2222                  valueGetter: p => p.data?.warehouse?.reserved ?? '' },
 2223                { colId: 'currency', headerName: 'Валюта', width: 80, rowSpan: span, cellClassRules: spanCls,
 2224                  valueGetter: p => p.data?.first ? p.data.currency : '' }
 2225            ]
 2226        });
 2227    }
 2228
 2229    function renderOrderDetailedInformationForBitrix(response, pricesTradesoft) {
 2230        // Берем изображения из imageUrls (новое поле в конце ответа)
 2231        let allImages = response.imageUrls || [];
 2232
 2233        // Если imageUrls пустой, используем fallback изображение
 2234        if (allImages.length === 0) {
 2235            allImages = ["https://s3.timeweb.cloud/25f554fc-6f66254e-9650-4d17-8e13-77b5b7d3242e/AppData/Studio2/IMG/300
 2236        }
 2237
 2238        // Определяем главное изображение (первое в списке)
 2239        const mainImage = allImages[0];
 2240
 2241        // Миниатюры (все изображения кроме первого, ограничиваем до 4 штук)
 2242        const limitedMiniatures = allImages.slice(1, 5);
 2243
 2244        const orderId = 1;
 2245
 2246        // Цветовая карта для поставщиков
 2247        const colorMap = {
 2248            'https://www.maxi.parts/': 'text-success',
 2249            'https://www.abcp.ru/': 'text-primary',
 2250            'https://www.avdmotors.ru/': 'text-info'
 2251        };
 2252
 2253        return `
 2254    <div class="d-flex" style="align-items: stretch;">
 2255        <div class="main-content me-4" style="flex: 1;">
 2256            ${response.detailInfo?.description || response.productInfo?.description ? `
 2257                <p>
 2258                    ${(response.detailInfo?.description || response.productInfo?.description).length > 100
 2259            ? (response.detailInfo?.description || response.productInfo?.description).slice(0, 100) + '...'
 2260            : (response.detailInfo?.description || response.productInfo?.description)}
 2261                </p>
 2262            ` : ''}
 2263
 2264            <div class="d-flex justify-content-start align-items-start">
 2265                <div class="gallery float-start me-3">
 2266                    <div class="main-image mb-2">
 2267                        <img src="${mainImage}" alt="Main Image" class="img-fluid rounded border" style="width: 265px; h
 2268                    </div>
 2269                    ${limitedMiniatures.length > 0 ? `
 2270                        <div class="miniatures d-flex justify-content-start">
 2271                            ${limitedMiniatures.map((img, index) => `
 2272                                <div class="miniature mx-1">
 2273                                    <img src="${img}" alt="Thumbnail" class="img-thumbnail" style="width: 60px; height: 
 2274                                </div>
 2275                            `).join('')}
 2276                        </div>
 2277                    ` : ''}
 2278                </div>
 2279
 2280                <div class="mb-3">
 2281                    ${response.detailInfo?.articleSchema?.dataSupplierArticleNumber || response.productInfo?.articlE_NUM
 2282            ? `
 2283                                <strong>Артикул:</strong>
 2284                                ${response.detailInfo?.articleSchema?.dataSupplierArticleNumber || response.productInfo?
 2285                                ${response.detailInfo?.normalizedArticle && response.detailInfo.normalizedArticle !== (r
 2286                ? `(${response.detailInfo.normalizedArticle})`
 2287                : ''}
 2288
 2289                                                ${response.detailInfo?.supplier?.name || response.productInfo?.manufactu
 2290                ? `: ${response.detailInfo?.supplier?.name || response.productInfo?.manufacturer}`
 2291                : ''}
 2292
 2293                                                ${response.detailInfo?.supplier?.code || response.productInfo?.manufactu
 2294                ? ` ${response.detailInfo?.supplier?.code || response.productInfo?.manufacturer}`
 2295                : ''}
 2296
 2297                                                ${response.detailInfo?.supplier?.rating !== undefined
 2298                ? ` ${response.detailInfo.supplier.rating}`
 2299                : ''}
 2300
 2301                                                ${response.detailInfo?.supplier?.tecdocSupplierId || response.productInf
 2302                ? `(${response.detailInfo?.supplier?.tecdocSupplierId || response.productInfo?.teC_DOC_PROD})`
 2303                : ''}
 2304
 2305                                                ${response.detailInfo?.description || response.productInfo?.description
 2306                ? ` ${(response.detailInfo?.description || response.productInfo?.description).length > 30
 2307                    ? (response.detailInfo?.description || response.productInfo?.description).slice(0, 30) + '...'
 2308                    : (response.detailInfo?.description || response.productInfo?.description)}`
 2309                : ''}
 2310                                                <br/>
 2311                                            `
 2312            : ''}
 2313                    <br/>
 2314                    ${response.productInfo ? `
 2315                        <strong>Доп. информация:</strong><br>
 2316                        <ul style="padding-left: 20px;">
 2317                            ${response.productInfo.shorT_DESCRIPTION ? `<li><strong>Категория:</strong> ${response.produ
 2318                            ${response.productInfo.barcodes ? `<li><strong>Штрих-коды:</strong> ${response.productInfo.b
 2319                            ${response.productInfo.packagE_WEIGHT ? `
 2320                                <li>
 2321                                    <strong>Вес:</strong>
 2322                                    <span class="weight-value"
 2323                                          style="cursor: pointer; text-decoration: underline; color: blue;"
 2324                                          data-weight="${response.productInfo.packagE_WEIGHT}"
 2325                                          data-order-id="${response.orderId || ''}">
 2326                                        ${response.productInfo.packagE_WEIGHT} кг
 2327                                    </span>
 2328                                </li>
 2329                            ` : ''}
 2330                            ${response.productInfo.packagE_LENGTH && response.productInfo.packagE_WIDTH && response.prod
 2331            `<li><strong>Габариты упаковки:</strong> ${response.productInfo.packagE_LENGTH}×${response.productInfo.packa
 2332                        </ul>
 2333                    ` : ''}
 2334
 2335                    ${response.detailInfo?.detailAttributes?.length > 0 ? `
 2336                    <strong>Характеристики:</strong><br>
 2337                    <ul class="attributes-list" style="font-size: 18px; max-height: 200px; overflow-y: auto; padding-lef
 2338                        ${response.detailInfo.detailAttributes
 2339            .filter(attr => attr.displayTitle || attr.description || attr.displayValue)
 2340            .map(attr => {
 2341                const title = attr.displayTitle || attr.description || '';
 2342                const value = attr.displayValue || '';
 2343                const isWeight = /Вес \[кг\]|Масса/i.test(title);
 2344
 2345                const displayValue = isWeight && value
 2346                    ? `<span class="weight-value"
 2347                                             style="cursor: pointer; text-decoration: underline; color: blue;"
 2348                                             data-weight="${value}"
 2349                                             data-order-id="${orderId}">
 2350                                           ${value} кг
 2351                                       </span>`
 2352                    : value ? `- ${value.length > 15 ? value.slice(0, 15) + '...' : value}` : '';
 2353
 2354                return `<li title="${attr.description || ''} - ${value}">
 2355                                            ${title.length > 15 ? title.slice(0, 15) + '...' : title} ${displayValue}
 2356                                        </li>`;
 2357            }).join('')}
 2358                    </ul>
 2359                ` : ''}
 2360
 2361                    <div class="form-group d-flex align-items-center gap-2">
 2362                        <label for="ProductWeight_${orderId}" class="form-label mb-0">Вес:</label>
 2363                        <input class="form-control form-control-sm product-weight-field" id="ProductWeight_${orderId}" s
 2364                    </div>
 2365                </div>
 2366            </div>
 2367        </div>
 2368        <div class="table-content"
 2369             style="width: 60%; height: 400px; overflow-y: auto;">
 2370            ${generateStockTradeTable(orderId, pricesTradesoft, colorMap)}
 2371            ${applyColumnState('.stock-trade-table')}
 2372        </div>
 2373    </div>
 2374    `;
 2375    }
 2376
 2377    function deletePriceHistory(id, bitrixId) {
 2378        St2Dialog.confirm('Вы уверены, что хотите удалить эту запись?', { variant: 'danger', confirmText: 'Удалить' })
 2379            .then(ok => {
 2380                if (!ok) return;
 2381                deletePriceHistoryConfirmed(id, bitrixId);
 2382            });
 2383        return false;
 2384    }
 2385
 2386    function deletePriceHistoryConfirmed(id, bitrixId) {
 2387        const token = $('input[name="__RequestVerificationToken"]').val();
 2388
 2389        $.ajax({
 2390            url: `/PriceHistory/Delete/${id}`,
 2391            type: 'POST',
 2392            headers: {
 2393                'RequestVerificationToken': token,
 2394                'X-Requested-With': 'XMLHttpRequest'
 2395            },
 2396            success: function (response) {
 2397                if (response === 'ok') {
 2398                    showNotification('Запись успешно удалена!', 'success');
 2399                    loadPriceHistory(bitrixId);
 2400                } else {
 2401                    showNotification('Ошибка при удалении записи', 'error');
 2402                }
 2403            },
 2404            error: function (xhr, status, error) {
 2405                showNotification('Ошибка сервера: ' + error, 'error');
 2406            }
 2407        });
 2408
 2409        return false; // Предотвращаем стандартную отправку формы
 2410    }
 2411
 2412    // Функция для отображения таблицы истории цен
 2413    // История цен в модалке товара — грид AG Grid (сортировка, фильтр по источнику/автору), колонки выровнены
 2414    // под форму новой цены над ним. Разметка — контейнер, грид монтирует mountPriceHistoryGrid().
 2415    let _priceHistoryRows = [];
 2416    let _priceHistoryGridApi = null;
 2417
 2418    function renderPriceHistoryTable(data) {
 2419        _priceHistoryRows = data || [];
 2420        if (!data || data.length === 0) {
 2421            return `
 2422                <div class="alert alert-info">
 2423                    <i class="bi bi-info-circle"></i> История изменений цен не найдена
 2424                </div>
 2425            `;
 2426        }
 2427        return '<div id="priceHistoryGrid" class="bsx-grid bsx-grid--modal"></div>';
 2428    }
 2429
 2430    function mountPriceHistoryGrid() {
 2431        const el = document.getElementById('priceHistoryGrid');
 2432        if (_priceHistoryGridApi) { try { _priceHistoryGridApi.destroy(); } catch (_) { } _priceHistoryGridApi = null; }
 2433        if (!el || !window.BsxGrid) return;
 2434        const set = { filter: window.BsxGrid.SetFilter, floatingFilterComponent: window.BsxGrid.SetFloatingFilter };
 2435        _priceHistoryGridApi = window.BsxGrid.create(el, {
 2436            key: 'priceHistory',
 2437            rowData: _priceHistoryRows,
 2438            getRowId: p => String(p.data.id),
 2439            columnDefs: [
 2440                { field: 'bitrixId', headerName: 'Bitrix ID', width: 110, valueFormatter: p => p.value || 'Не указан' },
 2441                { field: 'article', headerName: 'Артикул', width: 140, valueFormatter: p => p.value || 'Не указан' },
 2442                { field: 'priceValue', headerName: 'Цена', width: 110, type: 'numericColumn', filter: 'agNumberColumnFil
 2443                  valueGetter: p => p.data?.priceValue ? Math.round(parseFloat(p.data.priceValue)) : 0, valueFormatter: 
 2444                { field: 'valueSource', headerName: 'Источник', flex: 1, minWidth: 150, valueFormatter: p => p.value || 
 2445                { field: 'author', headerName: 'Автор', width: 150, valueFormatter: p => p.value || 'Не указан', ...set 
 2446                { field: 'createDateTime', headerName: 'Дата', width: 160, sort: 'desc', filter: false,
 2447                  valueFormatter: p => p.value ? new Date(p.value).toLocaleDateString() + ' ' + new Date(p.value).toLoca
 2448                { colId: 'actions', headerName: '', width: 90, sortable: false, filter: false, lockVisible: true, cellCl
 2449                  cellRenderer: p => p.data ? `<div class="bsx-cell-actions-row">
 2450                      <a class="btn btn-xs btn-outline-primary" href="/PriceHistory/Details/${p.data.id}" target="_blank
 2451                      <button type="button" class="btn btn-xs btn-outline-danger" title="Удалить запись" onclick="delete
 2452            ]
 2453        });
 2454    }
 2455
 2456
 2457    function openImage(imageUrl) {
 2458        window.open(imageUrl, '_blank');
 2459    }
 2460
 2461    $(document).on("click", ".show-bitrix-data", function () {
 2462        let article = $(this).data("article");
 2463        let article_with_key = $(this).data("article-with-key");
 2464        let productId = $(this).data("productid");
 2465        let supplier = $(this).data("supplier");
 2466        let previewText = $(this).data("previewtext");
 2467        let articleBlock = $(this).data("articleblock");
 2468
 2469        var priceInput = $('input[name="PriceValue"]');
 2470        priceInput.val('');
 2471
 2472        let storesData = $(this).data("stores");
 2473        let storeNames = [];
 2474        if (storesData) {
 2475            // data-stores приходит строкой: "Краснодар,СПБ_Гатчина,Ростов-на-Дону"
 2476            storeNames = storesData.split(",").map(s => s.trim());
 2477        }
 2478
 2479        let headerHtml = `
 2480                            <div class="d-flex flex-wrap gap-4 mb-2">
 2481                                <div>${articleBlock}</div>
 2482                                <div>${productId}</div>
 2483                                <div>${supplier}</div>
 2484                            </div>
 2485                            <div class="mb-2">
 2486                                ${previewText}
 2487                            </div>
 2488                            <hr>
 2489                        `;
 2490
 2491        $("#bitrixModalContent").html(`
 2492            <div class="d-flex justify-content-center align-items-center" style="height:200px;">
 2493                <div class="spinner-border text-primary" role="status">
 2494                    <span class="visually-hidden">Загрузка...</span>
 2495                </div>
 2496            </div>
 2497            <div class="text-center mt-3">
 2498                <a href="#" class="btn-load-tradesoft"
 2499                   data-article="${article}"
 2500                   data-supplier="${supplier}"
 2501                   style="display:none;">
 2502                    Загрузить доп. данные
 2503                </a>
 2504            </div>
 2505        `);
 2506
 2507        $("#priceHistorySection").show();
 2508        $("#historySpinner").show();
 2509        $("#priceHistoryTableContainer").empty();
 2510
 2511        $("#bitrixModal").modal("show");
 2512
 2513        loadPriceHistory(productId);
 2514
 2515        $.when(
 2516            fetchBitrixData(article),
 2517            fetchOzonData(normalizeOfferId(article_with_key, article), storeNames)
 2518        ).done(async function (bitrixResponse, ozonResponse) {
 2519            const unwrapWhenResult = (result) => {
 2520                // jQuery $.when with jqXHR passes [data, textStatus, jqXHR]
 2521                if (Array.isArray(result) && result.length === 3 && typeof result[1] === 'string') {
 2522                    return result[0];
 2523                }
 2524                // Native Promise or already-unwrapped value
 2525                return result;
 2526            };
 2527
 2528            let bitrixData = unwrapWhenResult(bitrixResponse);
 2529            let ozonData = unwrapWhenResult(ozonResponse);
 2530            if (!Array.isArray(bitrixData)) {
 2531                bitrixData = [];
 2532            }
 2533            if (!Array.isArray(ozonData)) {
 2534                ozonData = [];
 2535            }
 2536
 2537            const linkedStoreNames = getBitrixStoreNamesForOzonFetch(storeNames);
 2538            if (ozonData && Array.isArray(ozonData)) {
 2539                await Promise.all(ozonData
 2540                    .filter(cd => cd?.Client)
 2541                    .map(cd => ensureWarehouseMappingsForClient(cd.Client, linkedStoreNames)));
 2542                ozonData = await fetchOzonData(normalizeOfferId(article_with_key, article), linkedStoreNames);
 2543                if (!Array.isArray(ozonData)) {
 2544                    ozonData = [];
 2545                }
 2546                enrichOzonDataWithWarehouseConnections(ozonData);
 2547            }
 2548
 2549            let bitrixHtml = renderBitrixTable(bitrixData);
 2550            let ozonHtml = renderOzonTable(ozonData);
 2551
 2552            $("#bitrixModalContent").html(`
 2553                                            ${headerHtml}
 2554                                            <div class="tradesoft-section">
 2555                                                <div class="text-center mb-3">
 2556                                                    <a href="#" class="btn-load-tradesoft"
 2557                                                       data-article="${article}"
 2558                                                       data-supplier="${supplier}"
 2559                                                       style="display:none;">
 2560                                                        Загрузить доп. данные
 2561                                                    </a>
 2562                                                </div>
 2563                                                <div id="tradesoft-content"></div>
 2564                                            </div>
 2565                                            <hr>
 2566                                            `);
 2567
 2568            $("#ozonTableContainer").html(`
 2569                                            <div class="d-flex justify-content-center align-items-center" style="height:
 2570                                                <div class="spinner-border text-primary" role="status">
 2571                                                    <span class="visually-hidden">Загрузка Ozon...</span>
 2572                                                </div>
 2573                                            </div>
 2574                                        `);
 2575
 2576            $("#bitrixDataSection").show();
 2577            $("#bitrixTableContainer").html(bitrixHtml || "<p class='text-muted'>Нет данных</p>");
 2578            mountBitrixDataGrid();
 2579            var priceCell = $('[data-price-name="Учетная цена"]');
 2580            if (priceCell.length > 0) {
 2581                // Берем значение из атрибута data-price-value или текста ячейки
 2582                var priceValue = priceCell.attr('data-price-value') || priceCell.text();
 2583
 2584                // Заполняем только если поле пустое
 2585                var priceInput = $('input[name="PriceValue"]');
 2586                if (!priceInput.val().trim()) {
 2587                    priceInput.val(priceValue);
 2588                }
 2589            }
 2590
 2591            $("#ozonDataSection").show();
 2592            $("#ozonTableContainer").html(ozonHtml || "<p class='text-muted'>Нет данных Ozon</p>");
 2593            mountOzonDataGrid();
 2594            const inputVal = parseFloat($('input[name="PriceValue"]').val()) || 0;
 2595            recalcRanges(inputVal);
 2596
 2597            // Показываем форму
 2598            $("#priceHistoryFormSection").show();
 2599
 2600            // Обновляем поля формы
 2601            updatePriceHistoryForm(article, productId);
 2602            clearValidationErrors();
 2603
 2604            $(".btn-load-tradesoft").fadeIn();
 2605
 2606        }).fail(function () {
 2607            console.error("Ошибка при получении данных Bitrix или Ozon");
 2608            $("#bitrixModalContent").html(`
 2609                                        ${headerHtml}
 2610                                        <div class="tradesoft-section">
 2611                                            <div class="text-center mb-3">
 2612                                                <a href="#" class="btn-load-tradesoft"
 2613                                                   data-article="${article}"
 2614                                                   data-supplier="${supplier}"
 2615                                                   style="display:none;">
 2616                                                    Загрузить доп. данные
 2617                                                </a>
 2618                                            </div>
 2619                                            <div id="tradesoft-content"></div>
 2620                                        </div>
 2621                                        <hr>
 2622                                        <strong class='text-danger'>Не удалось загрузить данные Bitrix/Ozon</strong>
 2623                                        `);
 2624
 2625            // Все равно показываем форму и историю
 2626            $("#priceHistoryFormSection").show();
 2627            $("#bitrixDataSection").show();
 2628            $("#bitrixTableContainer").html("<p class='text-danger'>Не удалось загрузить данные Bitrix</p>");
 2629            $("#ozonDataSection").show();
 2630            $("#ozonTableContainer").html("<p class='text-danger'>Не удалось загрузить данные Ozon</p>");
 2631            updatePriceHistoryForm(article, productId);
 2632            clearValidationErrors();
 2633
 2634            $(".btn-load-tradesoft").fadeIn();
 2635        });
 2636    });
 2637
 2638
 2639    $(document).on("click", ".btn-load-tradesoft", function () {
 2640        let article = $(this).data("article");
 2641        let supplier = $(this).data("supplier");
 2642        let button = $(this);
 2643
 2644        button.hide();
 2645        $("#tradesoft-content").html(`
 2646        <div class="d-flex justify-content-center align-items-center" style="height:100px;">
 2647            <div class="spinner-border text-primary" role="status">
 2648                <span class="visually-hidden">Загрузка Tradesoft...</span>
 2649            </div>
 2650        </div>
 2651    `);
 2652
 2653        fetchOrderDetailedInformationForBitrix(article, supplier)
 2654            .done(function (tradesoftData) {
 2655                let tradesoftHtml = renderOrderDetailedInformationForBitrix(tradesoftData, []);
 2656                $("#tradesoft-content").html(tradesoftHtml);
 2657
 2658                $(".table-content").html(`
 2659                    <div class="d-flex justify-content-center align-items-center" style="height:150px;">
 2660                        <div class="spinner-border text-primary" role="status">
 2661                            <span class="visually-hidden">Загрузка цен...</span>
 2662                        </div>
 2663                    </div>
 2664                `);
 2665
 2666
 2667                GetCombinedDataForBitrix(article, supplier)
 2668                    .done(function (pricesTradesoft) {
 2669                        let orderId = tradesoftData.orderId || 1;
 2670                        const colorMap = {
 2671                            'https://www.maxi.parts/': 'text-success',
 2672                            'https://www.abcp.ru/': 'text-primary',
 2673                            'https://www.avdmotors.ru/': 'text-info'
 2674                        };
 2675
 2676                        $(".table-content").html(
 2677                            generateStockTradeTable(orderId, pricesTradesoft, colorMap)
 2678                        );
 2679                    })
 2680                    .fail(function (error) {
 2681                        $(".table-content").html(`
 2682                        <div class="alert alert-danger">
 2683                            Не удалось загрузить цены: ${error.statusText || 'Ошибка сервера'}
 2684                        </div>
 2685                    `);
 2686                    });
 2687
 2688            })
 2689            .fail(function (error) {
 2690                console.error("Ошибка при получении основных данных Tradesoft:", error);
 2691                $("#tradesoft-content").html(`
 2692                <div class="alert alert-danger">
 2693                    Не удалось загрузить данные: ${error.statusText || 'Ошибка сервера'}
 2694                </div>
 2695            `);
 2696            });
 2697    });
 2698
 2699    function checkWarehouseConnection(ozonWarehouseName, bitrixData, clientName) {
 2700
 2701        if (!window.warehouseMappings || !Array.isArray(window.warehouseMappings)) {
 2702            return false;
 2703        }
 2704        console.log(ozonWarehouseName, bitrixData, clientName);
 2705
 2706        const mapping = window.warehouseMappings.find(mapping =>
 2707            mapping.ozonWarehouseName === ozonWarehouseName &&
 2708            mapping.ozonClientName === clientName
 2709        );
 2710
 2711        if (!mapping) {
 2712            return false;
 2713        }
 2714        return true;
 2715    }
 2716
 2717    function updatePriceDisplay(orderId, indexRow) {
 2718        const $priceLink = $(`.price-link[data-order-id="${orderId}"][data-index-row="${indexRow}"]`);
 2719        const $priceBlock = $(`.cost-price-row[data-order-id="${orderId}"][data-index-row="${indexRow}"]`);
 2720        const $weightInput = $(`#ProductWeight_${orderId}`);
 2721        const $supplierSelect = $(`#SupplierId_${orderId}`);
 2722
 2723        const weight = parseFloat(($weightInput.val() || '0').replace(',', '.')) || 0;
 2724        const priceSite = $priceLink.data('site');
 2725
 2726        // Вывод в консоль исходных данных
 2727        console.log('====== РАСЧЕТ СТОИМОСТИ ======');
 2728        console.log(`Заказ: ${orderId}, Строка: ${indexRow}`);
 2729        console.log(`Сайт: ${priceSite}`);
 2730        console.log(`Вес товара: ${weight} кг`);
 2731
 2732        // Получаем данные из модели (предполагается, что suppliers доступен глобально)
 2733        const supplierFromModel = window.suppliersData.find(s => s.site === priceSite);
 2734        console.log(supplierFromModel);
 2735        let weightFactor = 0;
 2736        let costFactor = 1;
 2737        let weightCurrency = 'RUB';
 2738
 2739        if (supplierFromModel) {
 2740            weightFactor = parseFloat((supplierFromModel.weightFactor || '0').toString().replace(',', '.')) || 0;
 2741            costFactor = parseFloat((supplierFromModel.costFactor || '1').toString().replace(',', '.')) || 1;
 2742            weightCurrency = supplierFromModel.weightFactorCurrencyDisplayName || 'RUB';
 2743            console.log('Данные поставщика из модели:');
 2744            console.log(`• weightFactor: ${weightFactor}`);
 2745            console.log(`• costFactor: ${costFactor}`);
 2746            console.log(`• weightCurrency: ${weightCurrency}`);
 2747        } else {
 2748            const supplierOptionBySite = $supplierSelect.find(`option[data-site="${priceSite}"]`);
 2749            weightFactor = parseFloat((supplierOptionBySite.data('weight-factor') || '0').toString().replace(',', '.')) 
 2750            costFactor = parseFloat((supplierOptionBySite.data('cost-factor') || '1').toString().replace(',', '.')) || 1
 2751            weightCurrency = supplierOptionBySite.data('weight-currency-code') || 'RUB';
 2752            console.log('Данные поставщика из выпадающего списка:');
 2753            console.log(`• weightFactor: ${weightFactor}`);
 2754            console.log(`• costFactor: ${costFactor}`);
 2755            console.log(`• weightCurrency: ${weightCurrency}`);
 2756        }
 2757
 2758        const rawPrice = parseFloat(($priceLink.data('price') || '0').toString().replace(',', '.')) || 0;
 2759        const priceCurrency = $priceLink.data('currency-code') || 'RUB';
 2760
 2761        console.log('--- Данные о цене ---');
 2762        console.log(`• Базовая цена: ${rawPrice} ${priceCurrency}`);
 2763
 2764        const ratePrice = rates[priceCurrency] || 1;
 2765        const rateWeight = rates[weightCurrency] || 1;
 2766
 2767        console.log('--- Курсы валют ---');
 2768        console.log(`• Курс ${priceCurrency}/RUB: ${ratePrice}`);
 2769        console.log(`• Курс ${weightCurrency}/RUB: ${rateWeight}`);
 2770
 2771        // Промежуточные расчеты (если с бэкенда передан data-base-cost-rub — закупка по «входу», не по цене в ссылке)
 2772        const pricePerKgRUB = weightFactor * rateWeight;
 2773        const totalWeightCost = weight * pricePerKgRUB;
 2774
 2775        const baseCostAttr = $priceBlock.attr('data-base-cost-rub');
 2776        let baseCostRub = NaN;
 2777        if (baseCostAttr !== undefined && baseCostAttr !== null && String(baseCostAttr).trim() !== '') {
 2778            baseCostRub = parseFloat(String(baseCostAttr).replace(/\s/g, '').replace(',', '.'));
 2779        }
 2780
 2781        let purchasePriceRUB;
 2782        if (!isNaN(baseCostRub) && baseCostRub >= 0) {
 2783            purchasePriceRUB = baseCostRub * costFactor;
 2784        } else {
 2785            purchasePriceRUB = rawPrice * costFactor * ratePrice;
 2786        }
 2787
 2788        const totalRUB = Math.round(totalWeightCost + purchasePriceRUB);
 2789
 2790        console.log('--- Промежуточные расчеты ---');
 2791        console.log(`• Стоимость кг (weightFactor): ${weightFactor} × ${rateWeight} = ${pricePerKgRUB} RUB/кг`);
 2792        console.log(`• Стоимость веса: ${weight} кг × ${pricePerKgRUB} RUB/кг = ${totalWeightCost} RUB`);
 2793        if (!isNaN(baseCostRub) && baseCostRub >= 0) {
 2794            console.log(`• Цена закупки (вход API): ${baseCostRub} × ${costFactor} = ${purchasePriceRUB} RUB`);
 2795        } else {
 2796            console.log(`• Цена закупки: ${rawPrice} × ${costFactor} × ${ratePrice} = ${purchasePriceRUB} RUB`);
 2797        }
 2798        console.log(`• Итоговая стоимость: ${totalWeightCost} + ${purchasePriceRUB} = ${totalRUB} RUB`);
 2799
 2800        const formattedTotalRUB = totalRUB.toLocaleString('ru-RU');
 2801        const displayText = `${formattedTotalRUB} ₽`;
 2802
 2803        console.log('--- Результат ---');
 2804        console.log(`• Отображаемая цена: ${displayText}`);
 2805        console.log('==============================');
 2806
 2807        $priceBlock.text(displayText);
 2808        $priceBlock.attr('data-value', totalRUB);
 2809
 2810        if (window.tradesofDataCache && window.tradesofDataCache[orderId] && window.tradesofDataCache[orderId][indexRow]
 2811            window.tradesofDataCache[orderId][indexRow].costPrice = totalRUB;
 2812            window.tradesofDataCache[orderId][indexRow].costPriceFormatted = displayText;
 2813        }
 2814    }
 2815
 2816    function updateAllPricesForOrder(orderId) {
 2817        $(`.price-link[data-order-id="${orderId}"]`).each(function () {
 2818            const indexRow = $(this).data('index-row');
 2819            updatePriceDisplay(orderId, indexRow);
 2820        });
 2821    }
 2822
 2823    $(document).on('input', '.product-weight-field', function () {
 2824        const orderId = $(this).attr('id').split('_')[1];
 2825        updateAllPricesForOrder(orderId);
 2826        sortAndRerenderTable(orderId, 'costPrice', 'number', 'asc');
 2827    });
 2828
 2829    function fetchOrderDetailedInformationForBitrix(article, supplier) {
 2830        return $.ajax({
 02831            url: '@Url.Action("OrderDetailedInformationForBitrix", "Orders")',
 2832            type: "GET",
 2833            data: {
 2834                article: article,
 2835                producer: supplier
 2836            }
 2837        }).catch(error => {
 2838            if (error.status === 500) {
 2839                console.error('Ошибка 500 при получении данных от Tradesoft:', error);
 2840            }
 2841            return null;
 2842        });
 2843    }
 2844
 2845    // Функция для получения описания color_index
 2846    function getColorIndexDescription(colorIndex) {
 2847        const descriptions = {
 2848            "WITHOUT_INDEX": "Нет индекса",
 2849            "GREEN": "Выгодный",
 2850            "YELLOW": "Умеренный",
 2851            "RED": "Невыгодный"
 2852        };
 2853        return descriptions[colorIndex] || "Неизвестный индекс";
 2854    }
 2855
 2856    // Функция для получения цвета для подсветки
 2857    function getColorIndexColor(colorIndex) {
 2858        const colors = {
 2859            "WITHOUT_INDEX": "gray",
 2860            "GREEN": "green",
 2861            "YELLOW": "orange",
 2862            "RED": "red"
 2863        };
 2864        return colors[colorIndex] || "gray";
 2865    }
 2866
 2867    // Функция для получения AntiForgeryToken
 2868    function getAntiXsrfToken() {
 2869        return document.querySelector('input[name="__RequestVerificationToken"]').value;
 2870    }
 2871
 2872    // Функция для обновления полей формы при открытии модального окна
 2873    function updatePriceHistoryForm(article, productId) {
 2874        $('#modalArticle').val(article || '');
 2875        $('#modalBitrixId').val(productId || '');
 2876
 2877        // Устанавливаем текущую дату и время
 2878        const now = new Date().toISOString().slice(0, 16);
 2879        $('#modalCreateDateTime').val(now);
 2880    }
 2881
 2882    // Очистка ошибок валидации
 2883    function clearValidationErrors() {
 2884        $('.text-danger small').text('');
 2885        $('.form-control').removeClass('is-invalid');
 2886    }
 2887
 2888    // Показать ошибки валидации
 2889    function showValidationErrors(errors) {
 2890        clearValidationErrors();
 2891
 2892        for (const field in errors) {
 2893            if (errors.hasOwnProperty(field)) {
 2894                const errorElement = $(`#${field.toLowerCase()}Error`);
 2895                const inputElement = $(`[name="${field}"]`);
 2896
 2897                if (errorElement.length && inputElement.length) {
 2898                    errorElement.text(errors[field].join(', '));
 2899                    inputElement.addClass('is-invalid');
 2900                }
 2901            }
 2902        }
 2903    }
 2904
 2905    $(document).on('submit', '#priceHistoryForm', function (e) {
 2906        e.preventDefault();
 2907
 2908        const form = $(this);
 2909        // Исправьте URL - добавьте слеш в начале и / перед Area
 2910        const url = '/Studio2/PriceHistory/Create';
 2911        const formData = form.serialize();
 2912
 2913        // Получаем productId из формы
 2914        const productId = $('#modalBitrixId').val(); // Или другого поля, где хранится productId
 2915        console.log('Product ID:', productId);
 2916        console.log('Отправка данных:', formData);
 2917        console.log('URL:', url);
 2918
 2919        // Показываем индикатор загрузки
 2920        const submitBtn = form.find('button[type="submit"]');
 2921        const originalText = submitBtn.html();
 2922        submitBtn.html(`
 2923        <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
 2924        Добавление...
 2925    `).prop('disabled', true);
 2926
 2927        $.ajax({
 2928            url: url,
 2929            type: 'POST',
 2930            data: formData,
 2931            headers: {
 2932                'RequestVerificationToken': getAntiXsrfToken(),
 2933                'X-Requested-With': 'XMLHttpRequest'
 2934            },
 2935            success: function (result) {
 2936                console.log('Успешный ответ:', result);
 2937                if (result.success) {
 2938                    showNotification('Запись успешно создана!', 'success');
 2939                    const now = new Date().toISOString().slice(0, 16);
 2940                    $('#modalCreateDateTime').val(now);
 2941                    clearValidationErrors();
 2942
 2943                    // Вызываем обновление истории цен после успешного добавления
 2944                    if (productId) {
 2945                        loadPriceHistory(productId);
 2946                    } else {
 2947                        console.warn('Product ID не найден для обновления истории');
 2948                    }
 2949                }
 2950            },
 2951            error: function (xhr, status, error) {
 2952                console.log('Ошибка AJAX:', status, error, xhr.responseText);
 2953
 2954                try {
 2955                    const errorData = JSON.parse(xhr.responseText);
 2956                    if (errorData.errors) {
 2957                        showValidationErrors(errorData.errors);
 2958                        showNotification('Пожалуйста, исправьте ошибки в форме', 'error');
 2959                    } else {
 2960                        showNotification(errorData.error || 'Ошибка при создании записи', 'error');
 2961                    }
 2962                } catch (e) {
 2963                    showNotification('Ошибка сервера: ' + xhr.status, 'error');
 2964                }
 2965            },
 2966            complete: function () {
 2967                submitBtn.html(originalText).prop('disabled', false);
 2968            }
 2969        });
 2970    });
 2971
 2972    // Функция для показа уведомлений поверх всех окон
 2973    function showNotification(message, type = 'info') {
 2974        // Создаем контейнер для уведомлений если его нет
 2975        let notificationContainer = document.getElementById('notification-container');
 2976        if (!notificationContainer) {
 2977            notificationContainer = document.createElement('div');
 2978            notificationContainer.id = 'notification-container';
 2979            notificationContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 99999; display: fle
 2980            document.body.appendChild(notificationContainer);
 2981        }
 2982
 2983        // Создаем уведомление
 2984        const toast = document.createElement('div');
 2985        toast.className = `alert alert-${type} alert-dismissible fade show`;
 2986        toast.style.cssText = 'min-width: 300px; max-width: 400px; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);';
 2987        toast.innerHTML = `
 2988        ${message}
 2989        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
 2990    `;
 2991
 2992        // Добавляем уведомление в контейнер
 2993        notificationContainer.appendChild(toast);
 2994
 2995        // Инициализируем Bootstrap alert
 2996        const bsAlert = new bootstrap.Alert(toast);
 2997
 2998        // Автоматическое скрытие через 5 секунд
 2999        setTimeout(() => {
 3000            if (toast.parentNode) {
 3001                bsAlert.close();
 3002            }
 3003        }, 5000);
 3004
 3005        // Удаляем элемент из DOM после скрытия
 3006        toast.addEventListener('closed.bs.alert', function () {
 3007            if (toast.parentNode) {
 3008                toast.parentNode.removeChild(toast);
 3009            }
 3010        });
 3011    }
 3012
 3013    // Инициализация при загрузке страницы
 3014    $(document).ready(function () {
 3015        // Устанавливаем текущую дату при загрузке
 3016        const now = new Date().toISOString().slice(0, 16);
 3017        $('#modalCreateDateTime').val(now);
 3018    });
 3019
 3020
 3021    document.addEventListener("click", function (e) {
 3022        const button = e.target.closest(".stok-order");
 3023        if (!button) return;
 3024
 3025        const orderId = button.dataset.orderId;
 3026        const indexRow = button.dataset.indexRow;
 3027        const supplierUrl = button.dataset.supplierUrl || "";
 3028
 3029        const priceDiv = document.querySelector(
 3030            `.cost-price-row[data-order-id="${orderId}"][data-index-row="${indexRow}"]`
 3031        );
 3032
 3033        let costPrice = "";
 3034        if (priceDiv) {
 3035            costPrice = priceDiv.innerText.trim();
 3036            costPrice = costPrice.replace(/[^\d.,-]/g, "").replace(",", ".");
 3037        }
 3038
 3039        const modal = button.closest(".modal");
 3040        const priceInput = modal?.querySelector('input[name="PriceValue"]');
 3041        const sourceInput = modal?.querySelector('input[name="ValueSource"]');
 3042
 3043        if (priceInput) {
 3044            priceInput.value = costPrice;
 3045            const inputVal = parseFloat(costPrice) || 0;
 3046            recalcRanges(inputVal);
 3047        }
 3048
 3049        if (sourceInput) {
 3050            // ищем option в datalist с совпадающим url
 3051            const option = document.querySelector(
 3052                `#suppliersList option[data-url="${supplierUrl}"]`
 3053            );
 3054
 3055            if (option) {
 3056                sourceInput.value = option.value; // ставим название из списка
 3057            } else {
 3058                sourceInput.value = supplierUrl; // если не нашли, вставляем url как есть
 3059            }
 3060        }
 3061    });
 3062
 3063
 3064    // Функция загрузки истории цен
 3065    function loadPriceHistory(bitrixId) {
 3066        if (!bitrixId) {
 3067            $("#historySpinner").hide();
 3068            $("#priceHistoryTableContainer").html(`
 3069            <div class="alert alert-warning">
 3070                Bitrix ID не указан для загрузки истории
 3071            </div>
 3072        `);
 3073            return;
 3074        }
 3075
 3076        $.ajax({
 3077            url: `/Studio2/PriceHistory/SearchByBitrixId?bitrixId=${bitrixId}`,
 3078            type: 'GET',
 3079            beforeSend: function () {
 3080                $("#historySpinner").show();
 3081                $("#priceHistoryTableContainer").empty();
 3082            },
 3083            success: function (data) {
 3084                $("#historySpinner").hide();
 3085                const historyHtml = renderPriceHistoryTable(data);
 3086                $("#priceHistoryTableContainer").html(historyHtml);
 3087                mountPriceHistoryGrid();
 3088                if (data && data.length > 0 && data[0].priceValue !== null) {
 3089                    const priceValue = parseFloat(data[0].priceValue);
 3090                    $('input[name="PriceValue"]').val(priceValue.toFixed(2));
 3091                }
 3092            },
 3093            error: function (xhr, status, error) {
 3094                $("#historySpinner").hide();
 3095                console.error("Ошибка при загрузке истории цен:", error);
 3096                $("#priceHistoryTableContainer").html(`
 3097                <div class="alert alert-danger">
 3098                    <i class="bi bi-exclamation-triangle"></i>
 3099                    Ошибка при загрузке истории цен: ${xhr.status} ${xhr.statusText}
 3100                </div>
 3101            `);
 3102            }
 3103        });
 3104    }
 3105
 3106    function formatNumber(value) {
 3107        const number = parseFloat(value);
 3108        if (isNaN(number)) return value;
 3109        return Math.round(number).toLocaleString('ru-RU');
 3110    }
 3111
 3112    function buildCalculationPreview(value) {
 3113        const text = String(value ?? '').replace(/\s+/g, ' ').trim();
 3114        if (!text) return '';
 3115        return text.length > 110 ? `${text.slice(0, 110).trim()}...` : text;
 3116    }
 3117
 3118    function parseNumeric(value) {
 3119        const number = parseFloat(String(value ?? '').replace(',', '.'));
 3120        return Number.isFinite(number) ? number : 0;
 3121    }
 3122
 3123    function getRequestedUpdateTargetIndex(constantIndex, percent) {
 3124        const normalizedTargetIndex = Math.min(0.9, Math.max(0.8, constantIndex || 0.9));
 3125        const safetyAdjustment = Math.max(percent || 0, 0) / 1000;
 3126        return Math.min(0.9, Math.max(0.8, normalizedTargetIndex - safetyAdjustment));
 3127    }
 3128
 3129    function buildUpdatePriceHint(link) {
 3130        const client = _ozonClients[link.getAttribute('data-client-id')];
 3131        if (!client) return 'Нет данных для предварительного расчета';
 3132
 3133        const readPriceValue = (key) => parseNumeric(client.prices[key]);
 3134        const readCommissionValue = (key) => parseNumeric(client.commissions[key]);
 3135
 3136        const sellerPrice = readPriceValue('marketing_seller_price') || readPriceValue('price');
 3137        const customerPrice = readPriceValue('true_price') || readPriceValue('customer_price');
 3138        const ozonIndex = readCommissionValue('ozon_index_value');
 3139        const ozonMinPrice = readCommissionValue('ozon_min_price');
 3140        const externalIndex = readCommissionValue('external_index_value');
 3141        const externalMinPrice = readCommissionValue('external_min_price');
 3142        const constantIndex = parseNumeric(link.getAttribute('data-constant-index'));
 3143        const percent = parseNumeric(link.getAttribute('data-percent'));
 3144        const targetIndex = getRequestedUpdateTargetIndex(constantIndex, percent);
 3145
 3146        if (!sellerPrice || !customerPrice) {
 3147            return 'Недостаточно данных по цене для предварительного расчета';
 3148        }
 3149
 3150        const customerFactor = customerPrice / sellerPrice;
 3151        const candidates = [];
 3152
 3153        if (ozonIndex > 0) {
 3154            const targetCustomerPrice = customerPrice * targetIndex / ozonIndex;
 3155            candidates.push({
 3156                source: 'Ozon',
 3157                indexValue: ozonIndex,
 3158                sourcePrice: ozonMinPrice,
 3159                targetCustomerPrice,
 3160                targetSellerPrice: targetCustomerPrice / customerFactor
 3161            });
 3162        }
 3163
 3164        if (externalIndex > 0) {
 3165            const targetCustomerPrice = customerPrice * targetIndex / externalIndex;
 3166            candidates.push({
 3167                source: 'Другие площадки',
 3168                indexValue: externalIndex,
 3169                sourcePrice: externalMinPrice,
 3170                targetCustomerPrice,
 3171                targetSellerPrice: targetCustomerPrice / customerFactor
 3172            });
 3173        }
 3174
 3175        if (candidates.length === 0) {
 3176            return `Текущая цена с картой: ${Math.round(customerPrice)}. Нет индексов для предварительного расчета.`;
 3177        }
 3178
 3179        const bestCandidate = candidates.sort((left, right) => right.targetCustomerPrice - left.targetCustomerPrice)[0];
 3180
 3181        return `
 3182            <div style="max-width: 360px; font-size: 12px; line-height: 1.35;">
 3183                <div style="font-weight: 600; margin-bottom: 6px;">Предварительный пересчет цены</div>
 3184                <div><b>Текущая цена продавца:</b> ${Math.round(sellerPrice)}</div>
 3185                <div><b>Текущая цена с картой:</b> ${Math.round(customerPrice)}</div>
 3186                <div><b>Индекс Ozon:</b> ${ozonIndex ? ozonIndex.toFixed(2) : '-'}</div>
 3187                <div><b>Индекс внешнего рынка:</b> ${externalIndex ? externalIndex.toFixed(2) : '-'}</div>
 3188                <div style="margin-top: 6px;"><b>Целевой индекс:</b> clamp(${constantIndex.toFixed(2)}, 0.80, 0.90) - ${
 3189                <div><b>Коэффициент цены с картой:</b> ${customerPrice.toFixed(2)} / ${sellerPrice.toFixed(2)} = ${custo
 3190                <div style="margin-top: 6px;"><b>Выбран рынок:</b> ${bestCandidate.source}</div>
 3191                <div><b>Ориентир:</b> ${Math.round(bestCandidate.sourcePrice)} | индекс ${bestCandidate.indexValue.toFix
 3192                <div><b>Реком. цена с картой:</b> ${Math.round(bestCandidate.targetCustomerPrice)}</div>
 3193                <div><b>Реком. цена продавца:</b> ${Math.round(bestCandidate.targetSellerPrice)}</div>
 3194                <div style="margin-top: 6px; color: #6c757d;">Берется наименее агрессивный кандидат, чтобы не уводить ин
 3195            </div>
 3196        `.trim();
 3197    }
 3198
 3199    function updateUpdatePriceHint(link) {
 3200        const hint = buildUpdatePriceHint(link);
 3201        link.setAttribute('data-bs-content', hint);
 3202        const constantIndexSpan = link.querySelector('.constant-index');
 3203        const percentSpan = link.querySelector('.percent');
 3204        if (constantIndexSpan) constantIndexSpan.setAttribute('title', 'Изменяет целевой индекс пересчета');
 3205        if (percentSpan) percentSpan.setAttribute('title', 'Добавляет небольшой safety-сдвиг к целевому индексу');
 3206
 3207        if (window.bootstrap?.Popover) {
 3208            const popover = bootstrap.Popover.getOrCreateInstance(link, {
 3209                trigger: 'hover focus',
 3210                html: true,
 3211                placement: 'right',
 3212                sanitize: false,
 3213                container: 'body'
 3214            });
 3215
 3216            if (popover.tip) {
 3217                popover.setContent({ '.popover-body': hint });
 3218            }
 3219        }
 3220    }
 3221
 3222
 3223    function recalcRanges(inputVal) {
 3224        // Пустое значение — прочерки; иначе диапазон прибыли = цена − введённая цена − комиссия (макс./мин.).
 3225        const isEmptyValue = inputVal === 0 || inputVal === '' || inputVal === null || inputVal === undefined;
 3226        Object.values(_ozonClients).forEach(client => {
 3227            const basePrice = parseFloat(client.prices.price) || 0;
 3228            const minComm = parseFloat(client.prices.min_commission) || 0;
 3229            const maxComm = parseFloat(client.prices.max_commission) || 0;
 3230            client.rangeMin = isEmptyValue ? '—' : Math.round(basePrice - inputVal - maxComm);
 3231            client.rangeMax = isEmptyValue ? '—' : Math.round(basePrice - inputVal - minComm);
 3232        });
 3233        _ozonGridApi?.refreshCells({ columns: ['priceValue'], force: true });
 3234    }
 3235
 3236    document.addEventListener('input', function (e) {
 3237        if (e.target.matches('input[name="PriceValue"]')) {
 3238            const inputVal = parseFloat(e.target.value) || 0;
 3239            console.log("=== Пересчёт диапазонов === Введено:", inputVal);
 3240            recalcRanges(inputVal);
 3241        }
 3242    });
 3243
 3244    let selectedOzonWarehouse = null;
 3245
 3246    document.addEventListener('click', function (e) {
 3247        if (e.target.matches('.warehouse-link')) {
 3248            e.preventDefault();
 3249
 3250            const warehouseLink = e.target;
 3251
 3252            if (selectedOzonWarehouse === warehouseLink) {
 3253                resetOzonWarehouseSelection();
 3254                return;
 3255            }
 3256            resetOzonWarehouseSelection();
 3257            selectedOzonWarehouse = warehouseLink;
 3258            warehouseLink.classList.add('ozon-warehouse-selected');
 3259            showNotification('Теперь нажмите на склад в таблице Bitrix', 'info');
 3260        }
 3261
 3262        if (e.target.matches('.bitrix-stock') && selectedOzonWarehouse) {
 3263            e.preventDefault();
 3264
 3265            const bitrixLink = e.target;
 3266            const ozonData = {
 3267                OzonClient: selectedOzonWarehouse.getAttribute('data-client'),
 3268                OfferId: selectedOzonWarehouse.getAttribute('data-offer-id'),
 3269                ProductId: Number(selectedOzonWarehouse.getAttribute('data-product-id')),
 3270                Stock: Number(bitrixLink.getAttribute('data-amount')),
 3271                WarehouseId: Number(selectedOzonWarehouse.getAttribute('data-warehouse-id'))
 3272            };
 3273
 3274            const bitrixData = {
 3275                amount: bitrixLink.getAttribute('data-amount'),
 3276                name: bitrixLink.getAttribute('data-name')
 3277            };
 3278
 3279
 3280            $("#ozonTableContainer").html(`
 3281                            <div class="d-flex justify-content-center align-items-center" style="height:100px;">
 3282                                <div class="spinner-border text-primary" role="status">
 3283                                    <span class="visually-hidden">Загрузка Ozon...</span>
 3284                                </div>
 3285                            </div>
 3286                        `);
 3287
 3288            (async () => {
 3289                try {
 3290                    const response = await fetch('/BitrixStock/UpdateProductStocks', {
 3291                        method: 'POST',
 3292                        headers: {'Content-Type': 'application/json'},
 3293                        body: JSON.stringify(ozonData)
 3294                    });
 3295
 3296                    const data = await response.json();
 3297
 3298                    if (response.ok) {
 3299                        showNotification(data.message ?? "Остатки обновлены", 'success');
 3300
 3301                        // Создаем контейнер для лайв-обновлений
 3302                        createLiveUpdatesContainer();
 3303
 3304                        // Подключаемся к SignalR хабу для этого запроса
 3305                        if (data.connectionId) {
 3306                            await connectToPriceUpdates(data.connectionId);
 3307                        }
 3308
 3309                        await reloadOzonPanel(ozonData.OfferId, null, ozonData.OzonClient);
 3310                    } else {
 3311                        console.error('Ответ сервера (ошибка):', data);
 3312                        showNotification(data.message ?? "Ошибка при обновлении остатков", 'danger');
 3313                    }
 3314                } catch (error) {
 3315                    console.error('Ошибка запроса:', error);
 3316                    showNotification('Ошибка при отправке данных на сервер', 'danger');
 3317                }
 3318            })();
 3319
 3320            resetOzonWarehouseSelection();
 3321        }
 3322    });
 3323
 3324    // Хранилища выбранных элементов
 3325    const selectedRefreshItems = new Set(); // Для обновления
 3326    const selectedClearItems = new Set();   // Для обнуления
 3327    const selectedArchiveItems = new Set(); // Для архивации Ozon
 3328    const selectedUnarchiveItems = new Set(); // Для разархивации Ozon
 3329
 3330    // Обновление интерфейса выбранных элементов
 3331    function updateSelectedUI() {
 3332        const refreshContainer = document.getElementById('refreshSelectedContainer');
 3333        const clearContainer = document.getElementById('clearSelectedContainer');
 3334        const archiveContainer = document.getElementById('archiveSelectedContainer');
 3335        const unarchiveContainer = document.getElementById('unarchiveSelectedContainer');
 3336        const refreshCountElement = document.getElementById('refreshSelectedCount');
 3337        const clearCountElement = document.getElementById('clearSelectedCount');
 3338        const archiveCountElement = document.getElementById('archiveSelectedCount');
 3339        const unarchiveCountElement = document.getElementById('unarchiveSelectedCount');
 3340        const selectAllArchiveBtn = document.getElementById('triggerSelectAllArchiveable');
 3341        const selectAllUnarchiveBtn = document.getElementById('triggerSelectAllUnarchiveable');
 3342
 3343        // Для кнопки обновления выбранных
 3344        if (selectedRefreshItems.size > 0) {
 3345            refreshContainer.style.display = 'block';
 3346            refreshCountElement.textContent = selectedRefreshItems.size;
 3347        } else {
 3348            refreshContainer.style.display = 'none';
 3349        }
 3350
 3351        // Для кнопки обнуления выбранных
 3352        if (selectedClearItems.size > 0) {
 3353            clearContainer.style.display = 'block';
 3354            clearCountElement.textContent = selectedClearItems.size;
 3355        } else {
 3356            clearContainer.style.display = 'none';
 3357        }
 3358
 3359        if (selectedArchiveItems.size > 0) {
 3360            archiveContainer.style.display = 'block';
 3361            archiveCountElement.textContent = selectedArchiveItems.size;
 3362        } else {
 3363            archiveContainer.style.display = 'none';
 3364        }
 3365
 3366        if (selectedUnarchiveItems.size > 0) {
 3367            unarchiveContainer.style.display = 'block';
 3368            unarchiveCountElement.textContent = selectedUnarchiveItems.size;
 3369        } else {
 3370            unarchiveContainer.style.display = 'none';
 3371        }
 3372
 3373        const archivePicks = document.querySelectorAll('.ozon-archive-pick');
 3374        if (selectAllArchiveBtn) {
 3375            selectAllArchiveBtn.style.display = archivePicks.length > 0 ? 'block' : 'none';
 3376            if (archivePicks.length > 0) {
 3377                const allArchiveSelected = areAllArchivePicksOnPageSelected(archivePicks);
 3378                selectAllArchiveBtn.textContent = allArchiveSelected ? 'Снять «Нет»' : 'Выделить «Нет»';
 3379                selectAllArchiveBtn.title = allArchiveSelected
 3380                    ? 'Снять выделение со всех «Нет» на странице'
 3381                    : 'Выделить все не в архиве на странице';
 3382            }
 3383        }
 3384
 3385        const unarchivePicks = document.querySelectorAll('.ozon-unarchive-pick');
 3386        if (selectAllUnarchiveBtn) {
 3387            selectAllUnarchiveBtn.style.display = unarchivePicks.length > 0 ? 'block' : 'none';
 3388            if (unarchivePicks.length > 0) {
 3389                const allUnarchiveSelected = areAllUnarchivePicksOnPageSelected(unarchivePicks);
 3390                selectAllUnarchiveBtn.textContent = allUnarchiveSelected ? 'Снять «Да»' : 'Выделить «Да»';
 3391                selectAllUnarchiveBtn.title = allUnarchiveSelected
 3392                    ? 'Снять выделение со всех «Да» на странице'
 3393                    : 'Выделить все в архиве на странице';
 3394            }
 3395        }
 3396    }
 3397
 3398    function areAllArchivePicksOnPageSelected(picks) {
 3399        const nodes = picks || document.querySelectorAll('.ozon-archive-pick');
 3400        if (!nodes.length) return false;
 3401        for (const pick of nodes) {
 3402            const productId = Number(pick.getAttribute('data-ozon-product-id'));
 3403            const client = pick.getAttribute('data-ozon-client') || '';
 3404            if (!selectedArchiveItems.has(archiveSelectionKey(client, productId))) {
 3405                return false;
 3406            }
 3407        }
 3408        return true;
 3409    }
 3410
 3411    function areAllUnarchivePicksOnPageSelected(picks) {
 3412        const nodes = picks || document.querySelectorAll('.ozon-unarchive-pick');
 3413        if (!nodes.length) return false;
 3414        for (const pick of nodes) {
 3415            const productId = Number(pick.getAttribute('data-ozon-product-id'));
 3416            const client = pick.getAttribute('data-ozon-client') || '';
 3417            if (!selectedUnarchiveItems.has(unarchiveSelectionKey(client, productId))) {
 3418                return false;
 3419            }
 3420        }
 3421        return true;
 3422    }
 3423
 3424    function toggleSelectAllArchiveableOnPage() {
 3425        const picks = document.querySelectorAll('.ozon-archive-pick');
 3426        if (!picks.length) return;
 3427        const deselect = areAllArchivePicksOnPageSelected(picks);
 3428        picks.forEach(pick => {
 3429            const productId = Number(pick.getAttribute('data-ozon-product-id'));
 3430            const client = pick.getAttribute('data-ozon-client') || '';
 3431            const key = archiveSelectionKey(client, productId);
 3432            if (deselect) {
 3433                selectedArchiveItems.delete(key);
 3434                pick.classList.remove('selected-archive-item');
 3435            } else {
 3436                selectedArchiveItems.add(key);
 3437                pick.classList.add('selected-archive-item');
 3438            }
 3439        });
 3440        updateSelectedUI();
 3441        showNotification(
 3442            deselect ? 'Выделение «Нет» снято' : `Выделено ${selectedArchiveItems.size} позиций для архивации`,
 3443            'success');
 3444    }
 3445
 3446    function toggleSelectAllUnarchiveableOnPage() {
 3447        const picks = document.querySelectorAll('.ozon-unarchive-pick');
 3448        if (!picks.length) return;
 3449        const deselect = areAllUnarchivePicksOnPageSelected(picks);
 3450        picks.forEach(pick => {
 3451            const productId = Number(pick.getAttribute('data-ozon-product-id'));
 3452            const client = pick.getAttribute('data-ozon-client') || '';
 3453            const key = unarchiveSelectionKey(client, productId);
 3454            if (deselect) {
 3455                selectedUnarchiveItems.delete(key);
 3456                pick.classList.remove('selected-unarchive-item');
 3457            } else {
 3458                selectedUnarchiveItems.add(key);
 3459                pick.classList.add('selected-unarchive-item');
 3460            }
 3461        });
 3462        updateSelectedUI();
 3463        showNotification(
 3464            deselect ? 'Выделение «Да» снято' : `Выделено ${selectedUnarchiveItems.size} позиций для восстановления`,
 3465            'success');
 3466    }
 3467
 3468    function refreshSelectedStocks() {
 3469        if (selectedRefreshItems.size === 0) return;
 3470        const items = collectSelectedRefreshPayload();
 3471        if (!items.length) {
 3472            showNotification('Не выбраны позиции для наката', 'danger');
 3473            return;
 3474        }
 3475        openPushConfirmModal(items);
 3476    }
 3477
 03478    const clearJobStartUrl = '@Url.Action("StartClearJob", "BitrixStock", new { area = "Studio2" })';
 03479    const clearJobsListUrl = '@Url.Action("GetClearJobs", "BitrixStock", new { area = "Studio2" })';
 03480    const clearHistoryTabUrl = '@BuildClearHistoryTabUrl()';
 03481    const archiveJobStartUrl = '@Url.Action("StartArchiveJob", "BitrixStock", new { area = "Studio2" })';
 03482    const archiveJobsListUrl = '@Url.Action("GetArchiveJobs", "BitrixStock", new { area = "Studio2" })';
 03483    const unarchiveJobStartUrl = '@Url.Action("StartUnarchiveJob", "BitrixStock", new { area = "Studio2" })';
 03484    const unarchiveJobsListUrl = '@Url.Action("GetUnarchiveJobs", "BitrixStock", new { area = "Studio2" })';
 03485    const pushJobStartUrl = '@Url.Action("StartPushJob", "BitrixStock", new { area = "Studio2" })';
 03486    const pushJobsListUrl = '@Url.Action("GetPushJobs", "BitrixStock", new { area = "Studio2" })';
 03487    const pushHistoryTabUrl = '@BuildPushHistoryTabUrl()';
 3488    let pendingClearItems = [];
 3489    let pendingArchiveItems = [];
 3490    let pendingUnarchiveItems = [];
 3491    let pendingPushItems = [];
 3492    let bitrixClearJobConnection = null;
 3493    let bitrixArchiveJobConnection = null;
 3494    let bitrixUnarchiveJobConnection = null;
 3495    let bitrixClearElapsedTimer = null;
 3496    let bitrixClearStartedAt = 0;
 3497    let currentClearExecutionId = null;
 3498    let bitrixArchiveElapsedTimer = null;
 3499    let bitrixArchiveStartedAt = 0;
 3500    let currentArchiveExecutionId = null;
 3501    let currentUnarchiveExecutionId = null;
 3502    let bitrixUnarchiveElapsedTimer = null;
 3503    let bitrixUnarchiveStartedAt = 0;
 3504
 3505    function newClearJobId() {
 3506        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
 3507            return window.crypto.randomUUID();
 3508        }
 3509        return 'clear-' + Date.now() + '-' + Math.random().toString(16).slice(2);
 3510    }
 3511
 3512    function newArchiveJobId() {
 3513        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
 3514            return window.crypto.randomUUID();
 3515        }
 3516        return 'archive-' + Date.now() + '-' + Math.random().toString(16).slice(2);
 3517    }
 3518
 3519    function newUnarchiveJobId() {
 3520        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
 3521            return window.crypto.randomUUID();
 3522        }
 3523        return 'unarchive-' + Date.now() + '-' + Math.random().toString(16).slice(2);
 3524    }
 3525
 3526    function unarchiveSelectionKey(client, productId) {
 3527        return `unarchive_${(client || '').trim().toLowerCase()}_${productId}`;
 3528    }
 3529
 3530    function unarchiveItemFromPick(pick) {
 3531        const row = pick.closest('.bitrix-stock-row');
 3532        const isAuto = pick.getAttribute('data-is-auto-archived') === '1';
 3533        const isManual = pick.getAttribute('data-is-manual-archived') === '1';
 3534        return {
 3535            article: row?.getAttribute('data-article') || '',
 3536            offerId: pick.getAttribute('data-offer-id') || '',
 3537            ozonProductId: Number(pick.getAttribute('data-ozon-product-id')),
 3538            ozonClient: pick.getAttribute('data-ozon-client') || '',
 3539            wasInArchive: true,
 3540            isAutoArchived: isAuto,
 3541            isManualArchived: isManual
 3542        };
 3543    }
 3544
 3545    function toggleUnarchivePickSelection(pick) {
 3546        if (!pick) return;
 3547        const productId = Number(pick.getAttribute('data-ozon-product-id'));
 3548        const client = pick.getAttribute('data-ozon-client') || '';
 3549        const key = unarchiveSelectionKey(client, productId);
 3550        if (selectedUnarchiveItems.has(key)) {
 3551            selectedUnarchiveItems.delete(key);
 3552            pick.classList.remove('selected-unarchive-item');
 3553        } else {
 3554            selectedUnarchiveItems.add(key);
 3555            pick.classList.add('selected-unarchive-item');
 3556        }
 3557        updateSelectedUI();
 3558    }
 3559
 3560    function collectSelectedUnarchivePayload() {
 3561        const picks = document.querySelectorAll('.ozon-unarchive-pick.selected-unarchive-item');
 3562        const seen = new Set();
 3563        const items = [];
 3564        picks.forEach(pick => {
 3565            const item = unarchiveItemFromPick(pick);
 3566            const key = unarchiveSelectionKey(item.ozonClient, item.ozonProductId);
 3567            if (!item.ozonProductId || !item.ozonClient || seen.has(key)) return;
 3568            seen.add(key);
 3569            items.push(item);
 3570        });
 3571        return items;
 3572    }
 3573
 3574    // Модалки подтверждения массовых действий — гриды AG Grid (сортировка, фильтры, поиск по списку позиций).
 3575    const confirmSet = () => ({ filter: window.BsxGrid.SetFilter, floatingFilterComponent: window.BsxGrid.SetFloatingFil
 3576    const confirmTables = {
 3577        unarchive: window.BsxGrid.localTable('bitrixUnarchiveConfirmTable', { key: 'confirmUnarchive', columnDefs: [
 3578            { field: 'article', headerName: 'Артикул', width: 160, cellClass: 'fw-semibold' },
 3579            { field: 'offerId', headerName: 'Offer', flex: 1, minWidth: 160 },
 3580            { field: 'ozonClient', headerName: 'Клиент Ozon', width: 150, ...confirmSet() },
 3581            { colId: 'type', headerName: 'Тип архива', width: 130, valueGetter: p => p.data ? (p.data.isManualArchived ?
 3582            { field: 'ozonProductId', headerName: 'product_id', width: 130, type: 'numericColumn' }
 3583        ] }),
 3584        archive: window.BsxGrid.localTable('bitrixArchiveConfirmTable', { key: 'confirmArchive', columnDefs: [
 3585            { field: 'article', headerName: 'Артикул', width: 160, cellClass: 'fw-semibold' },
 3586            { field: 'offerId', headerName: 'Offer', flex: 1, minWidth: 160 },
 3587            { field: 'ozonClient', headerName: 'Клиент Ozon', width: 150, ...confirmSet() },
 3588            { field: 'ozonProductId', headerName: 'product_id', width: 130, type: 'numericColumn' }
 3589        ] }),
 3590        clear: window.BsxGrid.localTable('bitrixClearConfirmTable', { key: 'confirmClear', columnDefs: [
 3591            { field: 'article', headerName: 'Артикул', width: 160, cellClass: 'fw-semibold' },
 3592            { field: 'offerId', headerName: 'Offer', flex: 1, minWidth: 160 },
 3593            { field: 'ozonClient', headerName: 'Клиент Ozon', width: 150, ...confirmSet() },
 3594            { colId: 'warehouse', headerName: 'Склад', width: 170, valueGetter: p => p.data ? (p.data.warehouseName || p
 3595            { field: 'previousStock', headerName: 'Было', width: 90, type: 'numericColumn', filter: 'agNumberColumnFilte
 3596        ] }),
 3597        push: window.BsxGrid.localTable('bitrixPushConfirmTable', { key: 'confirmPush', columnDefs: [
 3598            { field: 'article', headerName: 'Артикул', width: 150, cellClass: 'fw-semibold' },
 3599            { field: 'offerId', headerName: 'Offer', flex: 1, minWidth: 150 },
 3600            { field: 'ozonClient', headerName: 'Клиент Ozon', width: 140, ...confirmSet() },
 3601            { colId: 'warehouse', headerName: 'Склад Ozon', width: 160, valueGetter: p => p.data ? (p.data.warehouseName
 3602            { field: 'linkedStoreName', headerName: 'Связь Bitrix', width: 120, valueFormatter: p => p.value || '—', ...
 3603            { field: 'previousStock', headerName: 'Ozon', width: 85, type: 'numericColumn', filter: 'agNumberColumnFilte
 3604            { field: 'targetStock', headerName: 'Цель', width: 85, type: 'numericColumn', filter: 'agNumberColumnFilter'
 3605        ] })
 3606    };
 3607
 3608    function openUnarchiveConfirmModal(items) {
 3609        pendingUnarchiveItems = items;
 3610        confirmTables.unarchive.setRows(items);
 3611        bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixUnarchiveConfirmModal')).show();
 3612    }
 3613
 3614    function unarchiveSelectedProducts() {
 3615        if (selectedUnarchiveItems.size === 0) return;
 3616        const items = collectSelectedUnarchivePayload();
 3617        if (!items.length) {
 3618            showNotification('Не выбраны позиции для восстановления', 'danger');
 3619            return;
 3620        }
 3621        openUnarchiveConfirmModal(items);
 3622    }
 3623
 3624    async function unarchiveSingleFromYesClick(pick) {
 3625        const item = unarchiveItemFromPick(pick);
 3626        if (!item.ozonProductId || !item.ozonClient) {
 3627            showNotification('Нет product_id Ozon для восстановления', 'warning');
 3628            return;
 3629        }
 3630        const typeHint = item.isAutoArchived ? '\n(авто-архив: лимит 100/сутки)' : '';
 3631        const msg = item.offerId
 3632            ? `Восстановить из архива Ozon?\nOffer: ${item.offerId}\nКлиент: ${item.ozonClient}${typeHint}`
 3633            : `Восстановить из архива Ozon?\nКлиент: ${item.ozonClient}${typeHint}`;
 3634        if (!(await St2Dialog.confirm(msg, { confirmText: 'Восстановить' }))) return;
 3635        pendingUnarchiveItems = [item];
 3636        await startBitrixUnarchiveJob({ silent: true });
 3637    }
 3638
 3639    function collectSelectedArchivePayload() {
 3640        const picks = document.querySelectorAll('.ozon-archive-pick.selected-archive-item');
 3641        const seen = new Set();
 3642        const items = [];
 3643        picks.forEach(pick => {
 3644            const productId = Number(pick.getAttribute('data-ozon-product-id'));
 3645            const client = pick.getAttribute('data-ozon-client') || '';
 3646            const offerId = pick.getAttribute('data-offer-id') || '';
 3647            const key = archiveSelectionKey(client, productId);
 3648            if (!productId || !client || seen.has(key)) {
 3649                return;
 3650            }
 3651            seen.add(key);
 3652            const row = pick.closest('.bitrix-stock-row');
 3653            items.push({
 3654                article: row?.getAttribute('data-article') || '',
 3655                offerId: offerId,
 3656                ozonProductId: productId,
 3657                ozonClient: client,
 3658                wasArchived: false
 3659            });
 3660        });
 3661        return items;
 3662    }
 3663
 3664    function openArchiveConfirmModal(items) {
 3665        pendingArchiveItems = items;
 3666        confirmTables.archive.setRows(items);
 3667        bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixArchiveConfirmModal')).show();
 3668    }
 3669
 3670    function archiveSelectedProducts() {
 3671        if (selectedArchiveItems.size === 0) return;
 3672        const items = collectSelectedArchivePayload();
 3673        if (!items.length) {
 3674            showNotification('Не выбраны позиции для архивации (нужен product_id Ozon)', 'danger');
 3675            return;
 3676        }
 3677        openArchiveConfirmModal(items);
 3678    }
 3679
 3680    function collectSelectedClearPayload() {
 3681        const selectedButtons = document.querySelectorAll('.clearOzonStock.selected-clear-item');
 3682        return Array.from(selectedButtons).map(btn => {
 3683            const row = btn.closest('.bitrix-stock-row');
 3684            const article = row?.getAttribute('data-article') || '';
 3685            return {
 3686                article: article,
 3687                offerId: btn.getAttribute('data-offer-id') || '',
 3688                productId: Number(btn.getAttribute('data-product-id')),
 3689                warehouseId: Number(btn.getAttribute('data-warehouse-id')),
 3690                warehouseName: btn.getAttribute('data-warehouse-name') || '',
 3691                ozonClient: btn.getAttribute('data-client') || '',
 3692                previousStock: Number(btn.getAttribute('data-previous-stock')) || 0
 3693            };
 3694        });
 3695    }
 3696
 3697    function openClearConfirmModal(items) {
 3698        pendingClearItems = items;
 3699        confirmTables.clear.setRows(items);
 3700        bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixClearConfirmModal')).show();
 3701    }
 3702
 3703    function escapeHtml(text) {
 3704        const div = document.createElement('div');
 3705        div.textContent = text ?? '';
 3706        return div.innerHTML;
 3707    }
 3708
 3709    function clearSelectedStocks() {
 3710        if (selectedClearItems.size === 0) return;
 3711        const items = collectSelectedClearPayload();
 3712        if (!items.length) {
 3713            showNotification('Не выбраны позиции для обнуления', 'danger');
 3714            return;
 3715        }
 3716        openClearConfirmModal(items);
 3717    }
 3718
 3719    function appendBitrixClearLog(message) {
 3720        const consoleEl = document.getElementById('bitrixClearLogConsole');
 3721        if (!consoleEl) return;
 3722        const line = document.createElement('div');
 3723        line.className = 'log-line';
 3724        const ts = new Date().toLocaleTimeString('ru-RU');
 3725        line.textContent = `[${ts}] ${message}`;
 3726        consoleEl.appendChild(line);
 3727        consoleEl.scrollTop = consoleEl.scrollHeight;
 3728    }
 3729
 3730    function setBitrixClearProgress(current, total, text) {
 3731        const bar = document.getElementById('bitrixClearProgressBar');
 3732        const txt = document.getElementById('bitrixClearProgressText');
 3733        if (!bar || !txt) return;
 3734        const pct = total > 0 ? Math.round((current / total) * 100) : 0;
 3735        bar.style.width = pct + '%';
 3736        bar.textContent = pct + '%';
 3737        txt.textContent = text || (total > 0 ? `Обработано ${current} из ${total}` : 'Выполнение…');
 3738    }
 3739
 3740    function resetBitrixClearProgressModal() {
 3741        const log = document.getElementById('bitrixClearLogConsole');
 3742        const bar = document.getElementById('bitrixClearProgressBar');
 3743        const summary = document.getElementById('bitrixClearResultSummary');
 3744        const reportWrap = document.getElementById('bitrixClearReportTableWrap');
 3745        const closeBtn = document.getElementById('bitrixClearProgressCloseBtn');
 3746        const closeFooter = document.getElementById('bitrixClearProgressFooterClose');
 3747        if (log) log.innerHTML = '';
 3748        if (bar) { bar.style.width = '0%'; bar.textContent = '0%'; }
 3749        if (summary) { summary.classList.add('d-none'); summary.innerHTML = ''; summary.classList.remove('ok'); }
 3750        if (reportWrap) reportWrap.classList.add('d-none');
 3751        if (closeBtn) closeBtn.style.display = 'none';
 3752        if (closeFooter) closeFooter.style.display = 'none';
 3753        if (bitrixClearElapsedTimer) clearInterval(bitrixClearElapsedTimer);
 3754        document.getElementById('bitrixClearElapsed').textContent = '00:00';
 3755    }
 3756
 3757    function startBitrixClearElapsedTimer() {
 3758        bitrixClearStartedAt = Date.now();
 3759        if (bitrixClearElapsedTimer) clearInterval(bitrixClearElapsedTimer);
 3760        bitrixClearElapsedTimer = setInterval(() => {
 3761            const s = Math.max(0, Math.floor((Date.now() - bitrixClearStartedAt) / 1000));
 3762            const el = document.getElementById('bitrixClearElapsed');
 3763            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 3764        }, 1000);
 3765    }
 3766
 3767    function finishBitrixClearModal(success) {
 3768        document.getElementById('bitrixClearProgressCloseBtn').style.display = '';
 3769        document.getElementById('bitrixClearProgressFooterClose').style.display = '';
 3770        const historyLink = document.getElementById('bitrixClearProgressHistoryLink');
 3771        if (historyLink) historyLink.style.display = '';
 3772        if (bitrixClearElapsedTimer) clearInterval(bitrixClearElapsedTimer);
 3773        const bar = document.getElementById('bitrixClearProgressBar');
 3774        if (bar) bar.classList.remove('progress-bar-animated');
 3775    }
 3776
 3777    async function connectBitrixClearSignalR(jobId) {
 3778        if (typeof signalR === 'undefined') {
 3779            appendBitrixClearLog('SignalR не загружен');
 3780            return;
 3781        }
 3782        if (bitrixClearJobConnection) {
 3783            try { await bitrixClearJobConnection.stop(); } catch (_) { }
 3784        }
 3785        bitrixClearJobConnection = new signalR.HubConnectionBuilder()
 3786            .withUrl('/jobProgressHub')
 3787            .withAutomaticReconnect()
 3788            .build();
 3789
 3790        bitrixClearJobConnection.on('Log', msg => appendBitrixClearLog(msg));
 3791        bitrixClearJobConnection.on('Progress', (current, total, text) => setBitrixClearProgress(current, total, text));
 3792        bitrixClearJobConnection.on('Complete', async (success, report) => {
 3793            const summary = document.getElementById('bitrixClearResultSummary');
 3794            if (summary) {
 3795                summary.innerHTML = report || '';
 3796                summary.classList.remove('d-none');
 3797                if (success) summary.classList.add('ok');
 3798            }
 3799            finishBitrixClearModal(success);
 3800            if (currentClearExecutionId) {
 3801                const historyLink = document.getElementById('bitrixClearProgressHistoryLink');
 3802                if (historyLink) {
 3803                    const sep = clearHistoryTabUrl.includes('?') ? '&' : '?';
 3804                    historyLink.href = `${clearHistoryTabUrl}${sep}executionId=${currentClearExecutionId}`;
 3805                }
 3806                await loadClearJobReportTable(currentClearExecutionId);
 3807            }
 3808            if (document.getElementById('bitrixClearJobsHistoryBody')) {
 3809                loadClearJobsHistory();
 3810            } else {
 3811                appendBitrixClearLog('Список запусков — вкладка «Обнуление» на этой странице.');
 3812            }
 3813            document.querySelectorAll('.clearOzonStock.selected-clear-item').forEach(btn => {
 3814                btn.classList.remove('selected-clear-item');
 3815                const productId = btn.getAttribute('data-product-id');
 3816                const warehouseId = btn.getAttribute('data-warehouse-id');
 3817                const stockDiv = document.querySelector(
 3818                    `.col-4.text-end[data-product-id="${productId}"][data-warehouse-id="${warehouseId}"]`
 3819                );
 3820                if (stockDiv) {
 3821                    stockDiv.textContent = '0';
 3822                }
 3823            });
 3824            selectedClearItems.clear();
 3825            updateSelectedUI();
 3826        });
 3827        bitrixClearJobConnection.on('Error', msg => {
 3828            appendBitrixClearLog('ОШИБКА: ' + msg);
 3829            finishBitrixClearModal(false);
 3830        });
 3831
 3832        await bitrixClearJobConnection.start();
 3833        await bitrixClearJobConnection.invoke('JoinJobGroup', jobId);
 3834        appendBitrixClearLog('Подключено к логам задания');
 3835    }
 3836
 3837    async function startBitrixClearJob() {
 3838        if (!pendingClearItems.length) return;
 3839        const jobId = newClearJobId();
 3840        const progressModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixClearProgressModal'));
 3841        bootstrap.Modal.getInstance(document.getElementById('bitrixClearConfirmModal'))?.hide();
 3842        resetBitrixClearProgressModal();
 3843        progressModal.show();
 3844        startBitrixClearElapsedTimer();
 3845        appendBitrixClearLog('Отправка задания на сервер…');
 3846
 3847        try {
 3848            const response = await fetch(clearJobStartUrl, {
 3849                method: 'POST',
 3850                headers: { 'Content-Type': 'application/json' },
 3851                body: JSON.stringify({ jobId: jobId, items: pendingClearItems })
 3852            });
 3853            const data = await response.json().catch(() => ({}));
 3854            if (!response.ok) {
 3855                throw new Error(data.error || 'Не удалось запустить задание');
 3856            }
 3857            currentClearExecutionId = data.executionId;
 3858            appendBitrixClearLog(`Задание #${data.executionId}, jobId=${jobId}`);
 3859            await connectBitrixClearSignalR(jobId);
 3860        } catch (err) {
 3861            appendBitrixClearLog('ОШИБКА: ' + (err.message || err));
 3862            finishBitrixClearModal(false);
 3863            showNotification(err.message || 'Ошибка запуска', 'danger');
 3864        }
 3865    }
 3866
 3867    async function loadClearJobReportTable(executionId) {
 3868        const url = `${clearJobsListUrl}/${executionId}`;
 3869        try {
 3870            const response = await fetch(url);
 3871            const data = await response.json();
 3872            if (!data.success || !data.execution?.items) return;
 3873            const wrap = document.getElementById('bitrixClearReportTableWrap');
 3874            if (!wrap) return;
 3875            wrap.classList.remove('d-none');
 3876            mountJobReportGrid('bitrixClearReportGrid', data.execution.items, 'stock');
 3877        } catch (e) {
 3878            console.warn('Отчёт не загружен', e);
 3879        }
 3880    }
 3881
 3882    // Отчёты задач (обнуление, накат, архивация, разархивация) в модалках прогресса — гриды AG Grid.
 3883    const _jobReportGrids = {};
 3884    function mountJobReportGrid(gridId, items, kind) {
 3885        const el = document.getElementById(gridId);
 3886        if (!el || !window.BsxGrid) return;
 3887        try { _jobReportGrids[gridId]?.destroy(); } catch (_) { }
 3888        const set = { filter: window.BsxGrid.SetFilter, floatingFilterComponent: window.BsxGrid.SetFloatingFilter };
 3889        const warehouse = { colId: 'warehouse', headerName: 'Склад', width: 170, valueGetter: p => p.data.warehouseName 
 3890        const client = { field: 'ozonClient', headerName: 'Клиент', width: 150, ...set };
 3891        const productId = { field: 'ozonProductId', headerName: 'product_id', width: 120, type: 'numericColumn' };
 3892        const extra = {
 3893            stock: [warehouse],
 3894            push: [warehouse, { colId: 'stock', headerName: 'Ozon → цель', width: 120, valueGetter: p => `${p.data.previ
 3895            archive: [client, productId],
 3896            unarchive: [client, { colId: 'type', headerName: 'Тип', width: 110, ...set,
 3897                valueGetter: p => p.data.isManualArchived ? 'Вручную' : p.data.isAutoArchived ? 'Системой' : '—' }, prod
 3898        }[kind] || [];
 3899        _jobReportGrids[gridId] = window.BsxGrid.create(el, {
 3900            key: 'bitrixJobReport',
 3901            rowData: items || [],
 3902            columnDefs: [
 3903                { field: 'article', headerName: 'Артикул', width: 150 },
 3904                { field: 'offerId', headerName: 'Offer', width: 160 },
 3905                ...extra,
 3906                { field: 'status', headerName: 'Статус', width: 110, ...set },
 3907                { field: 'errorMessage', headerName: 'Ошибка', flex: 1, minWidth: 200, tooltipField: 'errorMessage', cel
 3908            ],
 3909            gridOptions: {
 3910                rowClassRules: {
 3911                    'bsx-row-success': p => p.data?.status === 'Success',
 3912                    'bsx-row-danger': p => p.data?.status === 'Failed'
 3913                }
 3914            }
 3915        });
 3916    }
 3917
 3918    async function loadClearJobsHistory() {
 3919        const tbody = document.getElementById('bitrixClearJobsHistoryBody');
 3920        if (!tbody) return;
 3921        try {
 3922            const response = await fetch(`${clearJobsListUrl}?page=1&pageSize=20`);
 3923            const data = await response.json();
 3924            if (!data.success || !Array.isArray(data.items)) {
 3925                tbody.innerHTML = St2EmptyState.tableRow(8, { title: 'Нет данных', description: 'Для выбранных параметро
 3926                return;
 3927            }
 3928            if (data.items.length === 0) {
 3929                tbody.innerHTML = St2EmptyState.tableRow(8, { title: 'Заданий пока нет', description: 'Запустите операци
 3930                return;
 3931            }
 3932            tbody.innerHTML = data.items.map(e => {
 3933                const started = e.startedAt ? new Date(e.startedAt).toLocaleString('ru-RU') : '—';
 3934                const statusClass = e.status === 'Success' ? 'text-success' : e.status === 'Failed' ? 'text-danger' : 't
 3935                return `<tr>
 3936                    <td>${e.id}</td>
 3937                    <td>${started}</td>
 3938                    <td>${escapeHtml(e.triggeredByUserName || '—')}</td>
 3939                    <td>${e.totalItems}</td>
 3940                    <td>${e.successCount}</td>
 3941                    <td>${e.failedCount}</td>
 3942                    <td class="${statusClass}">${escapeHtml(e.status)}</td>
 3943                    <td><button type="button" class="btn btn-sm btn-outline-primary bitrix-clear-report-btn" data-execut
 3944                </tr>`;
 3945            }).join('');
 3946        } catch (err) {
 3947            tbody.innerHTML = `<tr><td colspan="8" class="text-danger">${escapeHtml(err.message)}</td></tr>`;
 3948        }
 3949    }
 3950
 3951    async function openClearJobReportModal(executionId) {
 3952        const url = `${clearJobsListUrl}/${executionId}`;
 3953        const response = await fetch(url);
 3954        const data = await response.json();
 3955        if (!data.success) {
 3956            showNotification(data.error || 'Не удалось загрузить отчёт', 'danger');
 3957            return;
 3958        }
 3959        const ex = data.execution;
 3960        document.getElementById('bitrixClearReportModalId').textContent = ex.id;
 3961        const errNote = ex.errorMessage ? `, примечание: ${ex.errorMessage}` : '';
 3962        document.getElementById('bitrixClearReportModalSummary').textContent =
 3963            `Статус: ${ex.status}, всего: ${ex.totalItems}, OK: ${ex.successCount}, ошибок: ${ex.failedCount}${errNote}`
 3964        const tbody = document.getElementById('bitrixClearReportModalBody');
 3965        tbody.innerHTML = (ex.items || []).map(item => `
 3966            <tr class="${item.status === 'Success' ? 'table-success' : item.status === 'Failed' ? 'table-danger' : ''}">
 3967                <td>${escapeHtml(item.article)}</td>
 3968                <td>${escapeHtml(item.offerId)}</td>
 3969                <td>${escapeHtml(item.ozonClient)}</td>
 3970                <td>${escapeHtml(item.warehouseName || item.warehouseId)}</td>
 3971                <td>${item.previousStock}</td>
 3972                <td>${escapeHtml(item.status)}</td>
 3973                <td class="small">${escapeHtml(item.errorMessage || '')}</td>
 3974            </tr>
 3975        `).join('');
 3976        bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixClearReportModal')).show();
 3977    }
 3978
 3979    function appendBitrixArchiveLog(message) {
 3980        const consoleEl = document.getElementById('bitrixArchiveLogConsole');
 3981        if (!consoleEl) return;
 3982        const line = document.createElement('div');
 3983        line.className = 'log-line';
 3984        const ts = new Date().toLocaleTimeString('ru-RU');
 3985        line.textContent = `[${ts}] ${message}`;
 3986        consoleEl.appendChild(line);
 3987        consoleEl.scrollTop = consoleEl.scrollHeight;
 3988    }
 3989
 3990    function setBitrixArchiveProgress(current, total, text) {
 3991        const bar = document.getElementById('bitrixArchiveProgressBar');
 3992        const txt = document.getElementById('bitrixArchiveProgressText');
 3993        if (!bar || !txt) return;
 3994        const pct = total > 0 ? Math.round((current / total) * 100) : 0;
 3995        bar.style.width = pct + '%';
 3996        bar.textContent = pct + '%';
 3997        txt.textContent = text || (total > 0 ? `Обработано ${current} из ${total}` : 'Выполнение…');
 3998    }
 3999
 4000    function resetBitrixArchiveProgressModal() {
 4001        const log = document.getElementById('bitrixArchiveLogConsole');
 4002        const bar = document.getElementById('bitrixArchiveProgressBar');
 4003        const summary = document.getElementById('bitrixArchiveResultSummary');
 4004        const reportWrap = document.getElementById('bitrixArchiveReportTableWrap');
 4005        const closeBtn = document.getElementById('bitrixArchiveProgressCloseBtn');
 4006        const closeFooter = document.getElementById('bitrixArchiveProgressFooterClose');
 4007        if (log) log.innerHTML = '';
 4008        if (bar) { bar.style.width = '0%'; bar.textContent = '0%'; bar.classList.add('progress-bar-animated'); }
 4009        if (summary) { summary.classList.add('d-none'); summary.innerHTML = ''; summary.classList.remove('ok'); }
 4010        if (reportWrap) reportWrap.classList.add('d-none');
 4011        if (closeBtn) closeBtn.style.display = 'none';
 4012        if (closeFooter) closeFooter.style.display = 'none';
 4013        if (bitrixArchiveElapsedTimer) clearInterval(bitrixArchiveElapsedTimer);
 4014        const el = document.getElementById('bitrixArchiveElapsed');
 4015        if (el) el.textContent = '00:00';
 4016    }
 4017
 4018    function startBitrixArchiveElapsedTimer() {
 4019        bitrixArchiveStartedAt = Date.now();
 4020        if (bitrixArchiveElapsedTimer) clearInterval(bitrixArchiveElapsedTimer);
 4021        bitrixArchiveElapsedTimer = setInterval(() => {
 4022            const s = Math.max(0, Math.floor((Date.now() - bitrixArchiveStartedAt) / 1000));
 4023            const el = document.getElementById('bitrixArchiveElapsed');
 4024            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 4025        }, 1000);
 4026    }
 4027
 4028    function finishBitrixArchiveModal() {
 4029        document.getElementById('bitrixArchiveProgressCloseBtn').style.display = '';
 4030        document.getElementById('bitrixArchiveProgressFooterClose').style.display = '';
 4031        if (bitrixArchiveElapsedTimer) clearInterval(bitrixArchiveElapsedTimer);
 4032        const bar = document.getElementById('bitrixArchiveProgressBar');
 4033        if (bar) bar.classList.remove('progress-bar-animated');
 4034    }
 4035
 4036    async function refreshArchiveCellsAfterJob(offerIds) {
 4037        const ids = [...new Set((offerIds || []).filter(Boolean))];
 4038        if (!ids.length) {
 4039            return;
 4040        }
 4041        for (const offerId of ids) {
 4042            const cell = document.querySelector(`.ozon-archive-cell[data-offer-id="${CSS.escape(offerId)}"]`);
 4043            if (cell) {
 4044                cell.innerHTML = '<span class="text-muted ozon-archive-placeholder small">…</span>';
 4045            }
 4046        }
 4047        await loadOzonArchiveForGrid();
 4048    }
 4049
 4050    async function connectBitrixArchiveSignalR(jobId, options = {}) {
 4051        const silent = options.silent === true;
 4052        if (typeof signalR === 'undefined') {
 4053            if (!silent) {
 4054                appendBitrixArchiveLog('SignalR не загружен');
 4055            }
 4056            return;
 4057        }
 4058        if (bitrixArchiveJobConnection) {
 4059            try { await bitrixArchiveJobConnection.stop(); } catch (_) { }
 4060        }
 4061        bitrixArchiveJobConnection = new signalR.HubConnectionBuilder()
 4062            .withUrl('/jobProgressHub')
 4063            .withAutomaticReconnect()
 4064            .build();
 4065
 4066        bitrixArchiveJobConnection.on('Log', msg => {
 4067            if (!silent) {
 4068                appendBitrixArchiveLog(msg);
 4069            }
 4070        });
 4071        bitrixArchiveJobConnection.on('Progress', (current, total, text) => {
 4072            if (!silent) {
 4073                setBitrixArchiveProgress(current, total, text);
 4074            }
 4075        });
 4076        bitrixArchiveJobConnection.on('Complete', async (success, report) => {
 4077            const offerIds = [...new Set(pendingArchiveItems.map(i => i.offerId).filter(Boolean))];
 4078            document.querySelectorAll('.ozon-archive-pick.selected-archive-item').forEach(pick => {
 4079                pick.classList.remove('selected-archive-item');
 4080            });
 4081            selectedArchiveItems.clear();
 4082            updateSelectedUI();
 4083
 4084            if (silent) {
 4085                hideLoadingSpinner();
 4086                showNotification(
 4087                    success ? 'Товар отправлен в архив Ozon' : (report || 'Ошибка архивации'),
 4088                    success ? 'success' : 'danger');
 4089            } else {
 4090                const summary = document.getElementById('bitrixArchiveResultSummary');
 4091                if (summary) {
 4092                    summary.innerHTML = report || '';
 4093                    summary.classList.remove('d-none');
 4094                    if (success) summary.classList.add('ok');
 4095                }
 4096                finishBitrixArchiveModal();
 4097                if (currentArchiveExecutionId) {
 4098                    await loadArchiveJobReportTable(currentArchiveExecutionId);
 4099                }
 4100            }
 4101
 4102            if (offerIds.length) {
 4103                await refreshArchiveCellsAfterJob(offerIds);
 4104            }
 4105        });
 4106        bitrixArchiveJobConnection.on('Error', msg => {
 4107            if (silent) {
 4108                hideLoadingSpinner();
 4109                showNotification(msg || 'Ошибка архивации', 'danger');
 4110            } else {
 4111                appendBitrixArchiveLog('ОШИБКА: ' + msg);
 4112                finishBitrixArchiveModal();
 4113            }
 4114        });
 4115
 4116        await bitrixArchiveJobConnection.start();
 4117        await bitrixArchiveJobConnection.invoke('JoinJobGroup', jobId);
 4118        if (!silent) {
 4119            appendBitrixArchiveLog('Подключено к логам задания');
 4120        }
 4121    }
 4122
 4123    async function startBitrixArchiveJob(options = {}) {
 4124        if (!pendingArchiveItems.length) return;
 4125        const silent = options.silent === true || pendingArchiveItems.length === 1;
 4126        const jobId = newArchiveJobId();
 4127        const progressModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixArchiveProgressModal'))
 4128        bootstrap.Modal.getInstance(document.getElementById('bitrixArchiveConfirmModal'))?.hide();
 4129
 4130        if (!silent) {
 4131            resetBitrixArchiveProgressModal();
 4132            progressModal.show();
 4133            startBitrixArchiveElapsedTimer();
 4134            appendBitrixArchiveLog('Отправка задания на сервер…');
 4135        } else {
 4136            showLoadingSpinner('Архивация в Ozon…');
 4137        }
 4138
 4139        try {
 4140            const response = await fetch(archiveJobStartUrl, {
 4141                method: 'POST',
 4142                headers: { 'Content-Type': 'application/json' },
 4143                body: JSON.stringify({ jobId: jobId, items: pendingArchiveItems })
 4144            });
 4145            const data = await response.json().catch(() => ({}));
 4146            if (!response.ok) {
 4147                throw new Error(data.error || 'Не удалось запустить задание');
 4148            }
 4149            currentArchiveExecutionId = data.executionId;
 4150            if (!silent) {
 4151                appendBitrixArchiveLog(`Задание #${data.executionId}, jobId=${jobId}`);
 4152            }
 4153            await connectBitrixArchiveSignalR(jobId, { silent });
 4154        } catch (err) {
 4155            if (silent) {
 4156                hideLoadingSpinner();
 4157            } else {
 4158                appendBitrixArchiveLog('ОШИБКА: ' + (err.message || err));
 4159                finishBitrixArchiveModal();
 4160            }
 4161            showNotification(err.message || 'Ошибка запуска', 'danger');
 4162        }
 4163    }
 4164
 4165    async function loadArchiveJobReportTable(executionId) {
 4166        try {
 4167            const response = await fetch(`${archiveJobsListUrl}/${executionId}`);
 4168            const data = await response.json();
 4169            if (!data.success || !data.execution?.items) return;
 4170            const wrap = document.getElementById('bitrixArchiveReportTableWrap');
 4171            if (!wrap) return;
 4172            wrap.classList.remove('d-none');
 4173            mountJobReportGrid('bitrixArchiveReportGrid', data.execution.items, 'archive');
 4174        } catch (e) {
 4175            console.warn('Отчёт архивации не загружен', e);
 4176        }
 4177    }
 4178
 4179    function appendBitrixUnarchiveLog(message) {
 4180        const consoleEl = document.getElementById('bitrixUnarchiveLogConsole');
 4181        if (!consoleEl) return;
 4182        const line = document.createElement('div');
 4183        line.className = 'log-line';
 4184        const ts = new Date().toLocaleTimeString('ru-RU');
 4185        line.textContent = `[${ts}] ${message}`;
 4186        consoleEl.appendChild(line);
 4187        consoleEl.scrollTop = consoleEl.scrollHeight;
 4188    }
 4189
 4190    function setBitrixUnarchiveProgress(current, total, text) {
 4191        const bar = document.getElementById('bitrixUnarchiveProgressBar');
 4192        const txt = document.getElementById('bitrixUnarchiveProgressText');
 4193        if (!bar || !txt) return;
 4194        const pct = total > 0 ? Math.round((current / total) * 100) : 0;
 4195        bar.style.width = pct + '%';
 4196        bar.textContent = pct + '%';
 4197        txt.textContent = text || (total > 0 ? `Обработано ${current} из ${total}` : 'Выполнение…');
 4198    }
 4199
 4200    function resetBitrixUnarchiveProgressModal() {
 4201        const log = document.getElementById('bitrixUnarchiveLogConsole');
 4202        const bar = document.getElementById('bitrixUnarchiveProgressBar');
 4203        const summary = document.getElementById('bitrixUnarchiveResultSummary');
 4204        const reportWrap = document.getElementById('bitrixUnarchiveReportTableWrap');
 4205        const closeBtn = document.getElementById('bitrixUnarchiveProgressCloseBtn');
 4206        const closeFooter = document.getElementById('bitrixUnarchiveProgressFooterClose');
 4207        if (log) log.innerHTML = '';
 4208        if (bar) { bar.style.width = '0%'; bar.textContent = '0%'; bar.classList.add('progress-bar-animated'); }
 4209        if (summary) { summary.classList.add('d-none'); summary.innerHTML = ''; summary.classList.remove('ok'); }
 4210        if (reportWrap) reportWrap.classList.add('d-none');
 4211        if (closeBtn) closeBtn.style.display = 'none';
 4212        if (closeFooter) closeFooter.style.display = 'none';
 4213        if (bitrixUnarchiveElapsedTimer) clearInterval(bitrixUnarchiveElapsedTimer);
 4214        const el = document.getElementById('bitrixUnarchiveElapsed');
 4215        if (el) el.textContent = '00:00';
 4216    }
 4217
 4218    function startBitrixUnarchiveElapsedTimer() {
 4219        bitrixUnarchiveStartedAt = Date.now();
 4220        if (bitrixUnarchiveElapsedTimer) clearInterval(bitrixUnarchiveElapsedTimer);
 4221        bitrixUnarchiveElapsedTimer = setInterval(() => {
 4222            const s = Math.max(0, Math.floor((Date.now() - bitrixUnarchiveStartedAt) / 1000));
 4223            const el = document.getElementById('bitrixUnarchiveElapsed');
 4224            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 4225        }, 1000);
 4226    }
 4227
 4228    function finishBitrixUnarchiveModal() {
 4229        document.getElementById('bitrixUnarchiveProgressCloseBtn').style.display = '';
 4230        document.getElementById('bitrixUnarchiveProgressFooterClose').style.display = '';
 4231        if (bitrixUnarchiveElapsedTimer) clearInterval(bitrixUnarchiveElapsedTimer);
 4232        const bar = document.getElementById('bitrixUnarchiveProgressBar');
 4233        if (bar) bar.classList.remove('progress-bar-animated');
 4234    }
 4235
 4236    async function connectBitrixUnarchiveSignalR(jobId, options = {}) {
 4237        const silent = options.silent === true;
 4238        if (typeof signalR === 'undefined') {
 4239            if (!silent) appendBitrixUnarchiveLog('SignalR не загружен');
 4240            return;
 4241        }
 4242        if (bitrixUnarchiveJobConnection) {
 4243            try { await bitrixUnarchiveJobConnection.stop(); } catch (_) { }
 4244        }
 4245        bitrixUnarchiveJobConnection = new signalR.HubConnectionBuilder()
 4246            .withUrl('/jobProgressHub')
 4247            .withAutomaticReconnect()
 4248            .build();
 4249
 4250        bitrixUnarchiveJobConnection.on('Log', msg => { if (!silent) appendBitrixUnarchiveLog(msg); });
 4251        bitrixUnarchiveJobConnection.on('Progress', (c, t, text) => { if (!silent) setBitrixUnarchiveProgress(c, t, text
 4252        bitrixUnarchiveJobConnection.on('Complete', async (success, report) => {
 4253            const offerIds = [...new Set(pendingUnarchiveItems.map(i => i.offerId).filter(Boolean))];
 4254            document.querySelectorAll('.ozon-unarchive-pick.selected-unarchive-item').forEach(p => p.classList.remove('s
 4255            selectedUnarchiveItems.clear();
 4256            updateSelectedUI();
 4257
 4258            if (silent) {
 4259                hideLoadingSpinner();
 4260                showNotification(
 4261                    success ? 'Товар восстановлен из архива Ozon' : (report || 'Ошибка разархивации'),
 4262                    success ? 'success' : 'danger');
 4263            } else {
 4264                const summary = document.getElementById('bitrixUnarchiveResultSummary');
 4265                if (summary) {
 4266                    summary.innerHTML = report || '';
 4267                    summary.classList.remove('d-none');
 4268                    if (success) summary.classList.add('ok');
 4269                }
 4270                finishBitrixUnarchiveModal();
 4271                if (currentUnarchiveExecutionId) await loadUnarchiveJobReportTable(currentUnarchiveExecutionId);
 4272            }
 4273            if (offerIds.length) await refreshArchiveCellsAfterJob(offerIds);
 4274        });
 4275        bitrixUnarchiveJobConnection.on('Error', msg => {
 4276            if (silent) {
 4277                hideLoadingSpinner();
 4278                showNotification(msg || 'Ошибка разархивации', 'danger');
 4279            } else {
 4280                appendBitrixUnarchiveLog('ОШИБКА: ' + msg);
 4281                finishBitrixUnarchiveModal();
 4282            }
 4283        });
 4284
 4285        await bitrixUnarchiveJobConnection.start();
 4286        await bitrixUnarchiveJobConnection.invoke('JoinJobGroup', jobId);
 4287        if (!silent) appendBitrixUnarchiveLog('Подключено к логам задания');
 4288    }
 4289
 4290    async function startBitrixUnarchiveJob(options = {}) {
 4291        if (!pendingUnarchiveItems.length) return;
 4292        const silent = options.silent === true || pendingUnarchiveItems.length === 1;
 4293        const jobId = newUnarchiveJobId();
 4294        const progressModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixUnarchiveProgressModal'
 4295        bootstrap.Modal.getInstance(document.getElementById('bitrixUnarchiveConfirmModal'))?.hide();
 4296
 4297        if (!silent) {
 4298            resetBitrixUnarchiveProgressModal();
 4299            progressModal.show();
 4300            startBitrixUnarchiveElapsedTimer();
 4301            appendBitrixUnarchiveLog('Отправка задания на сервер…');
 4302        } else {
 4303            showLoadingSpinner('Восстановление из архива Ozon…');
 4304        }
 4305
 4306        try {
 4307            const response = await fetch(unarchiveJobStartUrl, {
 4308                method: 'POST',
 4309                headers: { 'Content-Type': 'application/json' },
 4310                body: JSON.stringify({ jobId: jobId, items: pendingUnarchiveItems })
 4311            });
 4312            const data = await response.json().catch(() => ({}));
 4313            if (!response.ok) throw new Error(data.error || 'Не удалось запустить задание');
 4314            currentUnarchiveExecutionId = data.executionId;
 4315            if (!silent) appendBitrixUnarchiveLog(`Задание #${data.executionId}, jobId=${jobId}`);
 4316            await connectBitrixUnarchiveSignalR(jobId, { silent });
 4317        } catch (err) {
 4318            if (silent) hideLoadingSpinner();
 4319            else {
 4320                appendBitrixUnarchiveLog('ОШИБКА: ' + (err.message || err));
 4321                finishBitrixUnarchiveModal();
 4322            }
 4323            showNotification(err.message || 'Ошибка запуска', 'danger');
 4324        }
 4325    }
 4326
 4327    async function loadUnarchiveJobReportTable(executionId) {
 4328        try {
 4329            const response = await fetch(`${unarchiveJobsListUrl}/${executionId}`);
 4330            const data = await response.json();
 4331            if (!data.success || !data.execution?.items) return;
 4332            const wrap = document.getElementById('bitrixUnarchiveReportTableWrap');
 4333            if (!wrap) return;
 4334            wrap.classList.remove('d-none');
 4335            mountJobReportGrid('bitrixUnarchiveReportGrid', data.execution.items, 'unarchive');
 4336        } catch (e) {
 4337            console.warn('Отчёт разархивации не загружен', e);
 4338        }
 4339    }
 4340
 4341    document.getElementById('bitrixClearConfirmStartBtn')?.addEventListener('click', () => startBitrixClearJob());
 4342    document.getElementById('bitrixArchiveConfirmStartBtn')?.addEventListener('click', () => startBitrixArchiveJob());
 4343    document.getElementById('bitrixUnarchiveConfirmStartBtn')?.addEventListener('click', () => startBitrixUnarchiveJob()
 4344    document.getElementById('bitrixClearJobsRefreshBtn')?.addEventListener('click', () => loadClearJobsHistory());
 4345    document.getElementById('bitrixClearJobsHistoryBody')?.addEventListener('click', e => {
 4346        const btn = e.target.closest('.bitrix-clear-report-btn');
 4347        if (btn) {
 4348            openClearJobReportModal(Number(btn.getAttribute('data-execution-id')));
 4349        }
 4350    });
 4351    document.addEventListener('DOMContentLoaded', () => {
 4352        if (document.getElementById('bitrixClearJobsHistoryBody')) {
 4353            loadClearJobsHistory();
 4354        }
 4355        if (document.getElementById('bitrixPushJobsHistoryBody')) {
 4356            loadPushJobsHistory();
 4357        }
 4358        const deepLinkExecId = Number(new URLSearchParams(window.location.search).get('executionId'));
 4359        if (deepLinkExecId > 0) {
 4360            if (document.getElementById('bitrixClearJobsHistoryBody')) {
 4361                void openClearJobReportModal(deepLinkExecId);
 4362            } else if (document.getElementById('bitrixPushJobsHistoryBody')) {
 4363                void openPushJobReportModal(deepLinkExecId);
 4364            }
 4365        }
 4366    });
 4367
 4368    function newPushJobId() {
 4369        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
 4370            return window.crypto.randomUUID();
 4371        }
 4372        return 'push-' + Date.now() + '-' + Math.random().toString(16).slice(2);
 4373    }
 4374
 4375    function collectSelectedRefreshPayload() {
 4376        const selectedButtons = document.querySelectorAll('.refreshOzonMain.selected-refresh-item');
 4377        return Array.from(selectedButtons).map(btn => ({
 4378            article: btn.getAttribute('data-article') || '',
 4379            offerId: btn.getAttribute('data-offer-id') || '',
 4380            productId: Number(btn.getAttribute('data-product-id')),
 4381            warehouseId: Number(btn.getAttribute('data-warehouse-id')),
 4382            warehouseName: btn.getAttribute('data-warehouse-name') || '',
 4383            ozonClient: btn.getAttribute('data-client') || '',
 4384            linkedStoreName: btn.getAttribute('data-linked-store') || '',
 4385            previousStock: Number(btn.getAttribute('data-previous-stock')) || 0,
 4386            targetStock: Number(btn.getAttribute('data-stock')) || 0
 4387        }));
 4388    }
 4389
 4390    function openPushConfirmModal(items) {
 4391        pendingPushItems = items;
 4392        confirmTables.push.setRows(items);
 4393        bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixPushConfirmModal')).show();
 4394    }
 4395
 4396    let bitrixPushJobConnection = null;
 4397    let bitrixPushElapsedTimer = null;
 4398    let bitrixPushStartedAt = 0;
 4399    let currentPushExecutionId = null;
 4400
 4401    function appendBitrixPushLog(message) {
 4402        const consoleEl = document.getElementById('bitrixPushLogConsole');
 4403        if (!consoleEl) return;
 4404        const line = document.createElement('div');
 4405        line.className = 'log-line';
 4406        const ts = new Date().toLocaleTimeString('ru-RU');
 4407        line.textContent = `[${ts}] ${message}`;
 4408        consoleEl.appendChild(line);
 4409        consoleEl.scrollTop = consoleEl.scrollHeight;
 4410    }
 4411
 4412    function setBitrixPushProgress(current, total, text) {
 4413        const bar = document.getElementById('bitrixPushProgressBar');
 4414        const txt = document.getElementById('bitrixPushProgressText');
 4415        if (!bar || !txt) return;
 4416        const pct = total > 0 ? Math.round((current / total) * 100) : 0;
 4417        bar.style.width = pct + '%';
 4418        bar.textContent = pct + '%';
 4419        txt.textContent = text || (total > 0 ? `Обработано ${current} из ${total}` : 'Выполнение…');
 4420    }
 4421
 4422    function resetBitrixPushProgressModal() {
 4423        const log = document.getElementById('bitrixPushLogConsole');
 4424        const bar = document.getElementById('bitrixPushProgressBar');
 4425        const summary = document.getElementById('bitrixPushResultSummary');
 4426        const reportWrap = document.getElementById('bitrixPushReportTableWrap');
 4427        const closeBtn = document.getElementById('bitrixPushProgressCloseBtn');
 4428        const closeFooter = document.getElementById('bitrixPushProgressFooterClose');
 4429        if (log) log.innerHTML = '';
 4430        if (bar) { bar.style.width = '0%'; bar.textContent = '0%'; bar.classList.add('progress-bar-animated'); }
 4431        if (summary) { summary.classList.add('d-none'); summary.innerHTML = ''; summary.classList.remove('ok'); }
 4432        if (reportWrap) reportWrap.classList.add('d-none');
 4433        if (closeBtn) closeBtn.style.display = 'none';
 4434        if (closeFooter) closeFooter.style.display = 'none';
 4435        if (bitrixPushElapsedTimer) clearInterval(bitrixPushElapsedTimer);
 4436        const el = document.getElementById('bitrixPushElapsed');
 4437        if (el) el.textContent = '00:00';
 4438    }
 4439
 4440    function startBitrixPushElapsedTimer() {
 4441        bitrixPushStartedAt = Date.now();
 4442        if (bitrixPushElapsedTimer) clearInterval(bitrixPushElapsedTimer);
 4443        bitrixPushElapsedTimer = setInterval(() => {
 4444            const s = Math.max(0, Math.floor((Date.now() - bitrixPushStartedAt) / 1000));
 4445            const el = document.getElementById('bitrixPushElapsed');
 4446            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 4447        }, 1000);
 4448    }
 4449
 4450    function finishBitrixPushModal(success) {
 4451        document.getElementById('bitrixPushProgressCloseBtn').style.display = '';
 4452        document.getElementById('bitrixPushProgressFooterClose').style.display = '';
 4453        const historyLink = document.getElementById('bitrixPushProgressHistoryLink');
 4454        if (historyLink) historyLink.style.display = '';
 4455        if (bitrixPushElapsedTimer) clearInterval(bitrixPushElapsedTimer);
 4456        const bar = document.getElementById('bitrixPushProgressBar');
 4457        if (bar) bar.classList.remove('progress-bar-animated');
 4458        const summary = document.getElementById('bitrixPushResultSummary');
 4459        if (summary && success === false && summary.classList.contains('d-none')) {
 4460            summary.innerHTML = '<strong>Задание завершилось с ошибкой.</strong> См. лог выше.';
 4461            summary.classList.remove('d-none', 'ok');
 4462        }
 4463    }
 4464
 4465    async function connectBitrixPushSignalR(jobId) {
 4466        if (typeof signalR === 'undefined') {
 4467            appendBitrixPushLog('SignalR не загружен');
 4468            return;
 4469        }
 4470        if (bitrixPushJobConnection) {
 4471            try { await bitrixPushJobConnection.stop(); } catch (_) { }
 4472        }
 4473        bitrixPushJobConnection = new signalR.HubConnectionBuilder()
 4474            .withUrl('/jobProgressHub')
 4475            .withAutomaticReconnect()
 4476            .build();
 4477
 4478        bitrixPushJobConnection.on('Log', msg => appendBitrixPushLog(msg));
 4479        bitrixPushJobConnection.on('Progress', (current, total, text) => setBitrixPushProgress(current, total, text));
 4480        bitrixPushJobConnection.on('Complete', async (success, report) => {
 4481            const summary = document.getElementById('bitrixPushResultSummary');
 4482            if (summary) {
 4483                summary.innerHTML = report || '';
 4484                summary.classList.remove('d-none');
 4485                if (success) summary.classList.add('ok');
 4486            }
 4487            finishBitrixPushModal(success);
 4488            if (currentPushExecutionId) {
 4489                const historyLink = document.getElementById('bitrixPushProgressHistoryLink');
 4490                if (historyLink) {
 4491                    const sep = pushHistoryTabUrl.includes('?') ? '&' : '?';
 4492                    historyLink.href = `${pushHistoryTabUrl}${sep}executionId=${currentPushExecutionId}`;
 4493                }
 4494                await loadPushJobReportTable(currentPushExecutionId);
 4495            }
 4496            if (document.getElementById('bitrixPushJobsHistoryBody')) {
 4497                loadPushJobsHistory();
 4498            }
 4499            if (success) {
 4500            document.querySelectorAll('.refreshOzonMain.selected-refresh-item').forEach(btn => {
 4501                btn.classList.remove('selected-refresh-item');
 4502                const productId = btn.getAttribute('data-product-id');
 4503                const warehouseId = btn.getAttribute('data-warehouse-id');
 4504                const target = btn.getAttribute('data-stock');
 4505                const stockDiv = document.querySelector(
 4506                    `.col-4.text-end[data-product-id="${productId}"][data-warehouse-id="${warehouseId}"]`
 4507                );
 4508                if (stockDiv && target != null) {
 4509                    stockDiv.textContent = target;
 4510                }
 4511            });
 4512            selectedRefreshItems.clear();
 4513            updateSelectedUI();
 4514            }
 4515        });
 4516        bitrixPushJobConnection.on('Error', msg => {
 4517            appendBitrixPushLog('ОШИБКА: ' + msg);
 4518            finishBitrixPushModal(false);
 4519        });
 4520
 4521        await bitrixPushJobConnection.start();
 4522        await bitrixPushJobConnection.invoke('JoinJobGroup', jobId);
 4523        appendBitrixPushLog('Подключено к логам задания');
 4524    }
 4525
 4526    async function startBitrixPushJob() {
 4527        if (!pendingPushItems.length) return;
 4528        const jobId = newPushJobId();
 4529        const progressModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixPushProgressModal'));
 4530        bootstrap.Modal.getInstance(document.getElementById('bitrixPushConfirmModal'))?.hide();
 4531        resetBitrixPushProgressModal();
 4532        progressModal.show();
 4533        startBitrixPushElapsedTimer();
 4534        appendBitrixPushLog('Отправка задания на сервер…');
 4535
 4536        try {
 4537            const response = await fetch(pushJobStartUrl, {
 4538                method: 'POST',
 4539                headers: { 'Content-Type': 'application/json' },
 4540                body: JSON.stringify({ jobId: jobId, items: pendingPushItems })
 4541            });
 4542            const rawText = await response.text();
 4543            let data = {};
 4544            try { data = rawText ? JSON.parse(rawText) : {}; } catch (_) { }
 4545            if (!response.ok) {
 4546                const errMsg = data.error || data.title || rawText?.slice(0, 500) || `HTTP ${response.status}`;
 4547                throw new Error(errMsg);
 4548            }
 4549            currentPushExecutionId = data.executionId;
 4550            appendBitrixPushLog(`Задание #${data.executionId}, jobId=${jobId}`);
 4551            await connectBitrixPushSignalR(jobId);
 4552        } catch (err) {
 4553            appendBitrixPushLog('ОШИБКА: ' + (err.message || err));
 4554            finishBitrixPushModal(false);
 4555            showNotification(err.message || 'Ошибка запуска', 'danger');
 4556        }
 4557    }
 4558
 4559    async function loadPushJobReportTable(executionId) {
 4560        try {
 4561            const response = await fetch(`${pushJobsListUrl}/${executionId}`);
 4562            const data = await response.json();
 4563            if (!data.success || !data.execution?.items) return;
 4564            const wrap = document.getElementById('bitrixPushReportTableWrap');
 4565            if (!wrap) return;
 4566            wrap.classList.remove('d-none');
 4567            mountJobReportGrid('bitrixPushReportGrid', data.execution.items, 'push');
 4568        } catch (e) {
 4569            console.warn('Отчёт наката не загружен', e);
 4570        }
 4571    }
 4572
 4573    async function loadPushJobsHistory() {
 4574        const tbody = document.getElementById('bitrixPushJobsHistoryBody');
 4575        if (!tbody) return;
 4576        try {
 4577            const response = await fetch(`${pushJobsListUrl}?page=1&pageSize=20`);
 4578            const data = await response.json();
 4579            if (!data.success || !Array.isArray(data.items)) {
 4580                tbody.innerHTML = St2EmptyState.tableRow(8, { title: 'Нет данных', description: 'Для выбранных параметро
 4581                return;
 4582            }
 4583            if (data.items.length === 0) {
 4584                tbody.innerHTML = St2EmptyState.tableRow(8, { title: 'Заданий пока нет', description: 'Запустите операци
 4585                return;
 4586            }
 4587            tbody.innerHTML = data.items.map(e => {
 4588                const started = e.startedAt ? new Date(e.startedAt).toLocaleString('ru-RU') : '—';
 4589                const statusClass = e.status === 'Success' ? 'text-success' : e.status === 'Failed' ? 'text-danger' : 't
 4590                return `<tr>
 4591                    <td>${e.id}</td>
 4592                    <td>${started}</td>
 4593                    <td>${escapeHtml(e.triggeredByUserName || '—')}</td>
 4594                    <td>${e.totalItems}</td>
 4595                    <td>${e.successCount}</td>
 4596                    <td>${e.failedCount}</td>
 4597                    <td class="${statusClass}">${escapeHtml(e.status)}</td>
 4598                    <td><button type="button" class="btn btn-sm btn-outline-primary bitrix-push-report-btn" data-executi
 4599                </tr>`;
 4600            }).join('');
 4601        } catch (err) {
 4602            tbody.innerHTML = `<tr><td colspan="8" class="text-danger">${escapeHtml(err.message)}</td></tr>`;
 4603        }
 4604    }
 4605
 4606    async function openPushJobReportModal(executionId) {
 4607        const response = await fetch(`${pushJobsListUrl}/${executionId}`);
 4608        const data = await response.json();
 4609        if (!data.success) {
 4610            showNotification(data.error || 'Не удалось загрузить отчёт', 'danger');
 4611            return;
 4612        }
 4613        const ex = data.execution;
 4614        document.getElementById('bitrixPushReportModalId').textContent = ex.id;
 4615        const pushErrNote = ex.errorMessage ? `, примечание: ${ex.errorMessage}` : '';
 4616        document.getElementById('bitrixPushReportModalSummary').textContent =
 4617            `Статус: ${ex.status}, всего: ${ex.totalItems}, OK: ${ex.successCount}, ошибок: ${ex.failedCount}${pushErrNo
 4618        const tbody = document.getElementById('bitrixPushReportModalBody');
 4619        tbody.innerHTML = (ex.items || []).map(item => `
 4620            <tr class="${item.status === 'Success' ? 'table-success' : item.status === 'Failed' ? 'table-danger' : ''}">
 4621                <td>${escapeHtml(item.article)}</td>
 4622                <td>${escapeHtml(item.offerId)}</td>
 4623                <td>${escapeHtml(item.ozonClient)}</td>
 4624                <td>${escapeHtml(item.warehouseName || item.warehouseId)}</td>
 4625                <td>${escapeHtml(item.linkedStoreName || '—')}</td>
 4626                <td>${item.previousStock}</td>
 4627                <td>${item.targetStock}</td>
 4628                <td>${escapeHtml(item.status)}</td>
 4629                <td class="small">${escapeHtml(item.errorMessage || '')}</td>
 4630            </tr>
 4631        `).join('');
 4632        bootstrap.Modal.getOrCreateInstance(document.getElementById('bitrixPushReportModal')).show();
 4633    }
 4634
 4635    document.getElementById('bitrixPushConfirmStartBtn')?.addEventListener('click', () => startBitrixPushJob());
 4636    document.getElementById('bitrixPushJobsRefreshBtn')?.addEventListener('click', () => loadPushJobsHistory());
 4637    document.getElementById('bitrixPushJobsHistoryBody')?.addEventListener('click', e => {
 4638        const btn = e.target.closest('.bitrix-push-report-btn');
 4639        if (btn) {
 4640            openPushJobReportModal(Number(btn.getAttribute('data-execution-id')));
 4641        }
 4642    });
 4643
 4644
 4645    // Функция показа уведомлений
 4646    function showNotification(message, type = 'info') {
 4647        // Создаем элемент уведомления
 4648        const notification = document.createElement('div');
 4649        notification.className = `alert alert-${type === 'error' ? 'danger' : 'success'} alert-dismissible fade show pos
 4650        notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
 4651        notification.innerHTML = `
 4652        ${message}
 4653        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
 4654    `;
 4655
 4656        document.body.appendChild(notification);
 4657
 4658        // Автоматически скрываем через 5 секунд
 4659        setTimeout(() => {
 4660            if (notification.parentNode) {
 4661                notification.remove();
 4662            }
 4663        }, 5000);
 4664    }
 4665
 4666    document.addEventListener('click', async function (e) {
 4667        // Обработка одиночного обновления остатков (левая кнопка)
 4668        if (e.target.closest('.refreshOzonMain') && e.button === 0) {
 4669            e.preventDefault();
 4670
 4671            const btn = e.target.closest('.refreshOzonMain');
 4672
 4673            const ozonData = {
 4674                OzonClient: btn.getAttribute('data-client'),
 4675                OfferId: btn.getAttribute('data-offer-id'),
 4676                ProductId: Number(btn.getAttribute('data-product-id')),
 4677                Stock: Number(btn.getAttribute('data-stock')),
 4678                WarehouseId: Number(btn.getAttribute('data-warehouse-id'))
 4679            };
 4680
 4681            // Покажем индикатор загрузки на кнопке
 4682            const originalHtml = btn.innerHTML;
 4683            btn.disabled = true;
 4684            btn.innerHTML = `
 4685            <span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
 4686        `;
 4687            await updateOzonProductStock(ozonData);
 4688
 4689            // Вернём иконку обратно
 4690            btn.innerHTML = originalHtml;
 4691            btn.disabled = false;
 4692        }
 4693
 4694        // Обработка одиночного обнуления остатков (левая кнопка)
 4695        if (e.target.closest('.clearOzonStock') && e.button === 0) {
 4696            e.preventDefault();
 4697
 4698            const btn = e.target.closest('.clearOzonStock');
 4699
 4700            const ozonData = {
 4701                OzonClient: btn.getAttribute('data-client'),
 4702                OfferId: btn.getAttribute('data-offer-id'),
 4703                ProductId: Number(btn.getAttribute('data-product-id')),
 4704                Stock: 0, // Обнуляем остатки
 4705                WarehouseId: Number(btn.getAttribute('data-warehouse-id'))
 4706            };
 4707
 4708            // Покажем индикатор загрузки на кнопке
 4709            const originalHtml = btn.innerHTML;
 4710            btn.disabled = true;
 4711            btn.innerHTML = `
 4712            <span class="spinner-border spinner-border-sm text-danger" role="status" aria-hidden="true"></span>
 4713        `;
 4714            await updateOzonProductStock(ozonData);
 4715
 4716            // Вернём иконку обратно
 4717            btn.innerHTML = originalHtml;
 4718            btn.disabled = false;
 4719        }
 4720
 4721        // Обработка обновления выбранных
 4722        if (e.target.closest('#refreshSelectedBtn')) {
 4723            e.preventDefault();
 4724            refreshSelectedStocks();
 4725        }
 4726
 4727        // Обработка обнуления выбранных
 4728        if (e.target.closest('#clearSelectedBtn')) {
 4729            e.preventDefault();
 4730            await clearSelectedStocks();
 4731        }
 4732        if (e.target.closest('#archiveSelectedBtn')) {
 4733            e.preventDefault();
 4734            archiveSelectedProducts();
 4735        }
 4736        if (e.target.closest('#unarchiveSelectedBtn')) {
 4737            e.preventDefault();
 4738            unarchiveSelectedProducts();
 4739        }
 4740    });
 4741
 4742    // Обработка правой кнопки мыши на кнопках обновления
 4743    document.addEventListener('contextmenu', function (e) {
 4744        if (e.target.closest('.refreshOzonMain')) {
 4745            e.preventDefault();
 4746
 4747            const btn = e.target.closest('.refreshOzonMain');
 4748            const itemId = `${btn.getAttribute('data-product-id')}_${btn.getAttribute('data-warehouse-id')}`;
 4749
 4750            // Переключаем выделение
 4751            if (selectedRefreshItems.has(itemId)) {
 4752                selectedRefreshItems.delete(itemId);
 4753                btn.classList.remove('selected-refresh-item');
 4754            } else {
 4755                selectedRefreshItems.add(itemId);
 4756                btn.classList.add('selected-refresh-item');
 4757            }
 4758
 4759            // Обновляем UI
 4760            updateSelectedUI();
 4761        }
 4762    });
 4763
 4764    // Архив: «Нет» — в архив; клиент — выделение. Разархив: «Да» — из архива; клиент — выделение.
 4765    document.addEventListener('click', function (e) {
 4766        const unarchiveYes = e.target.closest('.ozon-unarchive-yes');
 4767        if (unarchiveYes) {
 4768            e.preventDefault();
 4769            e.stopPropagation();
 4770            const pick = unarchiveYes.closest('.ozon-unarchive-pick');
 4771            if (pick) unarchiveSingleFromYesClick(pick);
 4772            return;
 4773        }
 4774
 4775        const unarchiveToggle = e.target.closest('.ozon-unarchive-toggle-col');
 4776        if (unarchiveToggle) {
 4777            e.preventDefault();
 4778            toggleUnarchivePickSelection(unarchiveToggle.closest('.ozon-unarchive-pick'));
 4779            return;
 4780        }
 4781
 4782        const noBtn = e.target.closest('.ozon-archive-no');
 4783        if (noBtn) {
 4784            e.preventDefault();
 4785            e.stopPropagation();
 4786            const pick = noBtn.closest('.ozon-archive-pick');
 4787            if (pick) archiveSingleFromNoClick(pick);
 4788            return;
 4789        }
 4790
 4791        const toggleCol = e.target.closest('.ozon-archive-toggle-col');
 4792        if (toggleCol) {
 4793            e.preventDefault();
 4794            toggleArchivePickSelection(toggleCol.closest('.ozon-archive-pick'));
 4795        }
 4796    });
 4797
 4798    document.addEventListener('contextmenu', function (e) {
 4799        const unarchivePick = e.target.closest('.ozon-unarchive-pick');
 4800        if (unarchivePick && !e.target.closest('.ozon-unarchive-yes')) {
 4801            e.preventDefault();
 4802            toggleUnarchivePickSelection(unarchivePick);
 4803            return;
 4804        }
 4805
 4806        const pick = e.target.closest('.ozon-archive-pick');
 4807        if (pick && !e.target.closest('.ozon-archive-no')) {
 4808            e.preventDefault();
 4809            toggleArchivePickSelection(pick);
 4810        }
 4811    });
 4812
 4813    document.getElementById('triggerSelectAllUnarchiveable')?.addEventListener('click', function (e) {
 4814        e.preventDefault();
 4815        e.stopPropagation();
 4816        toggleSelectAllUnarchiveableOnPage();
 4817    });
 4818
 4819    document.getElementById('triggerSelectAllArchiveable')?.addEventListener('click', function (e) {
 4820        e.preventDefault();
 4821        e.stopPropagation();
 4822        toggleSelectAllArchiveableOnPage();
 4823    });
 4824
 4825    // Обработка правой кнопки мыши на кнопках обнуления
 4826    document.addEventListener('contextmenu', function (e) {
 4827        if (e.target.closest('.clearOzonStock')) {
 4828            e.preventDefault();
 4829
 4830            const btn = e.target.closest('.clearOzonStock');
 4831            const itemId = `${btn.getAttribute('data-product-id')}_${btn.getAttribute('data-warehouse-id')}`;
 4832
 4833            // Переключаем выделение
 4834            if (selectedClearItems.has(itemId)) {
 4835                selectedClearItems.delete(itemId);
 4836                btn.classList.remove('selected-clear-item');
 4837            } else {
 4838                selectedClearItems.add(itemId);
 4839                btn.classList.add('selected-clear-item');
 4840            }
 4841
 4842            // Обновляем UI
 4843            updateSelectedUI();
 4844        }
 4845    });
 4846
 4847    // CSS для выделенных элементов
 4848    const style = document.createElement('style');
 4849    style.textContent = `
 4850                       /* Стили для выделенных элементов обновления */
 4851                        .refreshOzonMain.selected-refresh-item {
 4852                            position: relative;
 4853                            background-color: #cce7ff !important;
 4854                            border-radius: 3px;
 4855                            box-shadow: 0 0 0 2px #007bff;
 4856                            padding: 2px !important;
 4857                        }
 4858                        .refreshOzonMain.selected-refresh-item svg {
 4859                            fill: #004085 !important;
 4860                            transform: scale(1.1);
 4861                        }
 4862
 4863                        /* Стили для выделенных элементов обнуления */
 4864                        .clearOzonStock.selected-clear-item {
 4865                            position: relative;
 4866                            background-color: #f8d7da !important;
 4867                            border-radius: 3px;
 4868                            box-shadow: 0 0 0 2px #dc3545;
 4869                            padding: 2px !important;
 4870                        }
 4871                        .clearOzonStock.selected-clear-item svg {
 4872                            fill: #721c24 !important;
 4873                            transform: scale(1.1);
 4874                        }
 4875
 4876                        .ozon-archive-cell .ozon-archive-line {
 4877                            display: flex;
 4878                            align-items: baseline;
 4879                            gap: 0.35rem;
 4880                            border-radius: 3px;
 4881                            margin-left: -2px;
 4882                            margin-right: -2px;
 4883                            padding: 1px 2px;
 4884                        }
 4885                        .ozon-archive-cell .ozon-archive-status {
 4886                            flex: 0 0 8.75rem;
 4887                            min-width: 8.75rem;
 4888                            white-space: nowrap;
 4889                        }
 4890                        .ozon-archive-cell .ozon-archive-client {
 4891                            flex: 1 1 auto;
 4892                            min-width: 0;
 4893                            line-height: 1.2;
 4894                        }
 4895                        .ozon-archive-no,
 4896                        .ozon-archive-no-label {
 4897                            color: #198754;
 4898                            font-weight: 500;
 4899                        }
 4900                        .ozon-unarchive-yes,
 4901                        .ozon-archive-yes-label {
 4902                            color: #dc3545;
 4903                            font-weight: 500;
 4904                        }
 4905                        .ozon-archive-pick.selected-archive-item {
 4906                            background-color: #f8d7da !important;
 4907                            box-shadow: inset 0 0 0 2px #dc3545;
 4908                        }
 4909                        .ozon-archive-pick.selected-archive-item .ozon-archive-no {
 4910                            color: #dc3545 !important;
 4911                        }
 4912                        .ozon-unarchive-pick.selected-unarchive-item {
 4913                            background-color: #d1e7dd !important;
 4914                            box-shadow: inset 0 0 0 2px #198754;
 4915                        }
 4916                        .ozon-unarchive-pick.selected-unarchive-item .ozon-unarchive-yes {
 4917                            color: #198754 !important;
 4918                        }
 4919                        .ozon-archive-no:hover,
 4920                        .ozon-unarchive-yes:hover {
 4921                            text-decoration: underline;
 4922                            font-weight: 600;
 4923                        }
 4924                        .ozon-archive-toggle-col:hover,
 4925                        .ozon-unarchive-toggle-col:hover {
 4926                            color: #212529 !important;
 4927                            font-weight: 600;
 4928                        }
 4929
 4930                        /* Анимация для кнопок */
 4931                        .refreshOzonMain, .clearOzonStock {
 4932                            transition: all 0.2s ease-in-out;
 4933                        }
 4934
 4935                        /* Убираем стандартные стили фокуса для этих кнопок */
 4936                        .refreshOzonMain:focus,
 4937                        .clearOzonStock:focus,
 4938                        .refreshOzonMain:focus-visible,
 4939                        .clearOzonStock:focus-visible {
 4940                            outline: none !important;
 4941                            box-shadow: none !important;
 4942                            border: none !important;
 4943                        }
 4944
 4945                        /* Убираем выделение при активации */
 4946                        .refreshOzonMain:active,
 4947                        .clearOzonStock:active {
 4948                            outline: none !important;
 4949                            box-shadow: none !important;
 4950                            border: none !important;
 4951                        }
 4952
 4953                        /* Убираем стандартное выделение браузера */
 4954                        .refreshOzonMain,
 4955                        .clearOzonStock {
 4956                            -webkit-tap-highlight-color: transparent;
 4957                            -webkit-user-select: none;
 4958                            -moz-user-select: none;
 4959                            -ms-user-select: none;
 4960                            user-select: none;
 4961                            outline: none !important;
 4962                            border: none !important;
 4963                        }
 4964
 4965                        /* Специфично для кнопок с type="button" */
 4966                        button.refreshOzonMain,
 4967                        button.clearOzonStock {
 4968                            outline: none !important;
 4969                            border: none !important;
 4970                        }
 4971
 4972                        /* Контейнеры для кнопок массовых операций */
 4973                        .mass-action-container {
 4974                            position: sticky;
 4975                            bottom: 20px;
 4976                            z-index: 1000;
 4977                            text-align: center;
 4978                            margin: 10px 0;
 4979                        }
 4980
 4981                        /* Расположение кнопок массовых операций */
 4982                        #refreshSelectedContainer {
 4983                            bottom: 80px;
 4984                        }
 4985
 4986                        #clearSelectedContainer {
 4987                            bottom: 20px;
 4988                        }
 4989                        `;
 4990    document.head.appendChild(style);
 4991
 4992    function isBitrixRowInactive(rowEl) {
 4993        return rowEl && String(rowEl.getAttribute('data-bitrix-active') || '').toUpperCase() === 'N';
 4994    }
 4995
 4996    function findBitrixStockRowForOzonUpdate(ozonData) {
 4997        if (!ozonData?.OfferId) return null;
 4998        const offer = String(ozonData.OfferId);
 4999        const article = offer.includes('=') ? offer.split('=')[0] : offer;
 5000        return document.querySelector(
 5001            `.bitrix-stock-row[data-article="${CSS.escape(article)}"]`) ||
 5002            document.querySelector(`.bitrix-stock-row[data-article="${CSS.escape(offer)}"]`);
 5003    }
 5004
 5005    async function updateOzonProductStock(ozonData) {
 5006        try {
 5007            const stockRow = findBitrixStockRowForOzonUpdate(ozonData);
 5008            if (isBitrixRowInactive(stockRow)) {
 5009                showNotification('Товар неактивен (Активность = Нет). Остаток в Ozon не изменяется.', 'warning');
 5010                return false;
 5011            }
 5012
 5013            if (ozonData) {
 5014                ozonData.BitrixActive = stockRow?.getAttribute('data-bitrix-active') || 'Y';
 5015            }
 5016
 5017            if (ozonData?.OzonClient) {
 5018                await ensureWarehouseMappingsForClient(ozonData.OzonClient, getBitrixStoreNamesForOzonFetch());
 5019            }
 5020
 5021            const response = await fetch('/BitrixStock/UpdateProductStocks', {
 5022                method: 'POST',
 5023                headers: {'Content-Type': 'application/json'},
 5024                body: JSON.stringify(ozonData)
 5025            });
 5026
 5027            const data = await response.json();
 5028
 5029            const resolvedProductId = Number(data.productId ?? data.ProductId ?? ozonData.ProductId) || 0;
 5030            if (resolvedProductId > 0) {
 5031                ozonData.ProductId = resolvedProductId;
 5032            }
 5033
 5034            if (response.ok) {
 5035                const refreshBtn = document.querySelector(
 5036                    `.refreshOzonMain[data-client="${ozonData.OzonClient}"][data-offer-id="${ozonData.OfferId}"][data-wa
 5037                );
 5038                const clearBtn = document.querySelector(
 5039                    `.clearOzonStock[data-client="${ozonData.OzonClient}"][data-offer-id="${ozonData.OfferId}"][data-war
 5040                );
 5041                if (resolvedProductId > 0) {
 5042                    refreshBtn?.setAttribute('data-product-id', String(resolvedProductId));
 5043                    clearBtn?.setAttribute('data-product-id', String(resolvedProductId));
 5044                }
 5045
 5046                // Если есть массив data.data, выводим подробности
 5047                if (Array.isArray(data.data) && data.data.length > 0) {
 5048                    const messages = data.data.map(item => {
 5049                        const stockDiv = document.querySelector(
 5050                            `.col-4.text-end[data-client="${ozonData.OzonClient}"][data-offer-id="${ozonData.OfferId}"][
 5051                        );
 5052                        if (stockDiv) {
 5053                            if (resolvedProductId > 0) {
 5054                                stockDiv.setAttribute('data-product-id', String(resolvedProductId));
 5055                            }
 5056                            stockDiv.textContent = ozonData.Stock;
 5057                            stockDiv.style.color = "";
 5058                        }
 5059
 5060                        return `${item.offerId || item.offer_id || "Неизвестный оффер"} — updated: ${item.updated}`;
 5061                    }).join('<br>');
 5062                    showNotification(`${data.message ?? "Остатки обновлены"}<br>${messages}`, 'success');
 5063                } else {
 5064                    showNotification(data.message ?? "Остатки обновлены", 'success');
 5065                }
 5066                return true;
 5067            } else {
 5068                // Если есть ошибки в массиве data.errors
 5069                if (Array.isArray(data.errors) && data.errors.length > 0) {
 5070                    const errorMessages = data.errors.map(err => {
 5071                        const offer = err.offerId ?? err.offer_id ?? "Неизвестный оффер";
 5072                        const text = err.message ?? err.Message ?? err.code ?? "ошибка Ozon";
 5073                        return `${offer} — ${text}`;
 5074                    }).join('<br>');
 5075                    showNotification(`${data.message ?? "Ошибка при обновлении остатков"}<br>${errorMessages}`, 'danger'
 5076                } else {
 5077                    showNotification(data.message ?? "Ошибка при обновлении остатков", 'danger');
 5078                }
 5079                console.error('Ответ сервера (ошибка):', data);
 5080                return false;
 5081            }
 5082        } catch (error) {
 5083            console.error('Ошибка запроса:', error);
 5084            showNotification('Ошибка при отправке данных на сервер', 'danger');
 5085            return false;
 5086        }
 5087    }
 5088
 5089
 5090    document.addEventListener('click', function (e) {
 5091        if (e.target.matches('.warehouse-set-link')) {
 5092            e.preventDefault();
 5093
 5094            const link = e.target.closest('.warehouse-set-link');
 5095            const offerId = link.getAttribute('data-offer-id');
 5096            const productId = Number(link.getAttribute('data-product-id'));
 5097            const clientName = link.getAttribute('data-client');
 5098            const warehouseId = Number(link.getAttribute('data-warehouse-id'));
 5099            const warehouseName = link.getAttribute('data-warehouse-name');
 5100            const currentStock = Number(link.getAttribute('data-total')) || 0;
 5101
 5102            // Модалка Bootstrap
 5103            const modalHtml = `
 5104            <div class="modal fade" id="setStockModal" tabindex="-1">
 5105              <div class="modal-dialog modal-dialog-centered">
 5106                <div class="modal-content">
 5107                  <div class="modal-header">
 5108                    <h5 class="modal-title">Установить остатки</h5>
 5109                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
 5110                  </div>
 5111                  <div class="modal-body">
 5112                    <p>Склад: <b>${warehouseName}</b></p>
 5113                    <label for="newStockInput" class="form-label">Введите новый остаток</label>
 5114                    <input type="number" step="1" id="newStockInput" class="form-control" value="${currentStock}" />
 5115                  </div>
 5116                  <div class="modal-footer">
 5117                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 5118                    <button type="button" class="btn btn-primary" id="confirmSetStock">Установить</button>
 5119                  </div>
 5120                </div>
 5121              </div>
 5122            </div>`;
 5123
 5124            // Если модалки нет — добавляем
 5125            if (!document.querySelector('#setStockModal')) {
 5126                document.body.insertAdjacentHTML('beforeend', modalHtml);
 5127            } else {
 5128                document.querySelector('#setStockModal .modal-body').innerHTML =
 5129                    `<p>Склад: <b>${warehouseName}</b></p>
 5130                 <label for="newStockInput" class="form-label">Введите новый остаток</label>
 5131                 <input type="number" step="1" id="newStockInput" class="form-control" value="${currentStock}" />`;
 5132            }
 5133
 5134            const modal = new bootstrap.Modal(document.getElementById('setStockModal'));
 5135            modal.show();
 5136
 5137            // Обработка кнопки "Установить"
 5138            document.getElementById('confirmSetStock').onclick = async function () {
 5139                const newStock = parseInt(document.getElementById('newStockInput').value, 10);
 5140                if (isNaN(newStock) || newStock < 0) {
 5141                    St2Dialog.alert('Введите корректное количество остатков', { variant: 'warning' });
 5142                    return;
 5143                }
 5144
 5145                modal.hide();
 5146
 5147                const payload = {
 5148                    OzonClient: clientName,
 5149                    OfferId: offerId,
 5150                    ProductId: productId,
 5151                    Stock: newStock,
 5152                    WarehouseId: warehouseId
 5153                };
 5154
 5155                $("#ozonTableContainer").html(`
 5156                <div class="d-flex justify-content-center align-items-center" style="height:100px;">
 5157                    <div class="spinner-border text-primary" role="status">
 5158                        <span class="visually-hidden">Загрузка Ozon...</span>
 5159                    </div>
 5160                </div>
 5161            `);
 5162
 5163                try {
 5164                    const response = await fetch('/BitrixStock/UpdateProductStocks', {
 5165                        method: 'POST',
 5166                        headers: {'Content-Type': 'application/json'},
 5167                        body: JSON.stringify(payload)
 5168                    });
 5169
 5170                    const data = await response.json();
 5171
 5172                    if (response.ok) {
 5173                        showNotification(data.message ?? "Остатки обновлены", 'success');
 5174
 5175                        await reloadOzonPanel(payload.OfferId, null, payload.OzonClient);
 5176                    } else {
 5177                        console.error('Ошибка сервера:', data);
 5178                        showNotification(data.message ?? "Ошибка при обновлении остатков", 'danger');
 5179                    }
 5180                } catch (error) {
 5181                    console.error('Ошибка запроса:', error);
 5182                    showNotification('Ошибка при отправке данных на сервер', 'danger');
 5183                }
 5184            };
 5185        }
 5186    });
 5187
 5188    // Функция сброса выбора склада Ozon
 5189    function resetOzonWarehouseSelection() {
 5190        if (selectedOzonWarehouse) {
 5191            selectedOzonWarehouse.classList.remove('ozon-warehouse-selected');
 5192            selectedOzonWarehouse = null;
 5193        }
 5194    }
 5195
 5196    document.addEventListener('click', function (e) {
 5197        const updatePriceLink = e.target.closest('.update_price');
 5198        if (updatePriceLink && !e.target.matches('.constant-index') && !e.target.matches('.percent')) {
 5199            e.preventDefault();
 5200
 5201            const link = updatePriceLink;
 5202            if (window.bootstrap?.Popover) {
 5203                const existingPopover = bootstrap.Popover.getInstance(link);
 5204                existingPopover?.hide();
 5205            }
 5206            link.blur();
 5207
 5208            const originalHtml = link.innerHTML;
 5209            const offerId = normalizeOfferId(link.getAttribute('data-offer-id'));
 5210            const clientName = link.getAttribute('data-client');
 5211            const constantIndex = parseFloat(link.getAttribute('data-constant-index'));
 5212            const percent = parseFloat(link.getAttribute('data-percent'));
 5213
 5214            showNotification(`Дождитесь, пока Ozon обновит цены и индексы для ${offerId}`, 'warning');
 5215
 5216
 5217            $("#ozonTableContainer").html(`
 5218                            <div class="d-flex justify-content-center align-items-center" style="height:100px;">
 5219                                <div class="spinner-border text-primary" role="status">
 5220                                    <span class="visually-hidden">Загрузка Ozon...</span>
 5221                                </div>
 5222                            </div>
 5223                        `);
 5224            link.disabled = true;
 5225
 5226            (async () => {
 5227                try {
 5228                    const response = await fetch('/BitrixStock/UpdateProductPrice', {
 5229                        method: 'POST',
 5230                        headers: {'Content-Type': 'application/json'},
 5231                        body: JSON.stringify({
 5232                            article: offerId,
 5233                            clientName: clientName,
 5234                            constantIndex: constantIndex,
 5235                            percent: percent
 5236                        })
 5237                    });
 5238
 5239                    const data = await response.json();
 5240                    await reloadOzonPanel(offerId, null, clientName);
 5241
 5242                    if (response.ok) {
 5243                        if (data.result && data.result.length > 0) {
 5244                            data.result.forEach(item => {
 5245                                if (item.updated) {
 5246                                    showNotification(`Цена для товара ${item.offerId} успешно обновлена <br/> ${item.mes
 5247                                } else if (item.errors && item.errors.length > 0) {
 5248                                    const errorList = item.errors.map(e => e.message).join('; ');
 5249                                    showNotification(`Ошибка для ${item.offerId}: ${errorList}`, 'danger');
 5250                                } else {
 5251                                    showNotification(`Для ${item.offerId} не удалось обновить цену`, 'warning');
 5252                                }
 5253                            });
 5254                        } else {
 5255                            showNotification('Сервер вернул пустой результат', 'warning');
 5256                        }
 5257                    } else {
 5258                        showNotification(data.message ?? 'Ошибка при обновлении цены', 'danger');
 5259                    }
 5260                } catch (error) {
 5261                    console.error('Ошибка запроса:', error);
 5262                    showNotification('Ошибка при отправке данных на сервер', 'danger');
 5263                } finally {
 5264                    link.innerHTML = originalHtml;
 5265                    link.disabled = false;
 5266                    updateUpdatePriceHint(link);
 5267                }
 5268            })();
 5269        }
 5270    });
 5271
 5272    document.addEventListener('click', function (e) {
 5273        if (e.target.matches('.set_price')) {
 5274            e.preventDefault();
 5275
 5276            const link = e.target.closest('.set_price');
 5277            const offerId = normalizeOfferId(link.getAttribute('data-offer-id'));
 5278            const clientName = link.getAttribute('data-client');
 5279            const priceValue = parseFloat(link.getAttribute('data-price-value')) || 0;
 5280
 5281            // Рассчитываем цену до скидки (на 25% больше)
 5282            const oldPriceValue = priceValue * 1.25;
 5283
 5284            const modalHtml = `
 5285        <div class="modal fade" id="setPriceModal" tabindex="-1">
 5286          <div class="modal-dialog modal-dialog-centered">
 5287            <div class="modal-content">
 5288              <div class="modal-header">
 5289                <h5 class="modal-title">Установить цены для <b>${offerId}</b></h5>
 5290                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
 5291              </div>
 5292              <div class="modal-body">
 5293                <div class="row">
 5294                  <div class="col-md-6 mb-3">
 5295                    <label for="yourPriceInput" class="form-label">Ваша цена, ₽</label>
 5296                    <input type="number" step="0.01" id="yourPriceInput" class="form-control" value="${Math.round(priceV
 5297                  </div>
 5298                  <div class="col-md-6 mb-3">
 5299                    <label for="oldPriceInput" class="form-label">Цена до скидки, ₽</label>
 5300                    <input type="number" step="0.01" id="oldPriceInput" class="form-control" value="${Math.round(oldPric
 5301                  </div>
 5302                </div>
 5303                <div class="row">
 5304                  <div class="col-md-6 mb-3">
 5305                    <label for="minPriceInput" class="form-label">Минимальная цена, ₽</label>
 5306                    <input type="number" step="0.01" id="minPriceInput" class="form-control" value="${Math.round(priceVa
 5307                  </div>
 5308                  <div class="col-md-6 mb-3">
 5309                    <label for="costPriceInput" class="form-label">Себестоимость, ₽</label>
 5310                    <input type="number" step="0.01" id="costPriceInput" class="form-control" placeholder="0.00" />
 5311                  </div>
 5312                </div>
 5313              </div>
 5314              <div class="modal-footer">
 5315                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
 5316                <button type="button" class="btn btn-primary" id="confirmSetPrice">Установить цены</button>
 5317              </div>
 5318            </div>
 5319          </div>
 5320        </div>`;
 5321
 5322            if (!document.querySelector('#setPriceModal')) {
 5323                document.body.insertAdjacentHTML('beforeend', modalHtml);
 5324            } else {
 5325                document.querySelector('#setPriceModal .modal-body').innerHTML = `
 5326                <div class="row">
 5327                  <div class="col-md-6 mb-3">
 5328                    <label for="yourPriceInput" class="form-label">Ваша цена, ₽</label>
 5329                    <input type="number" step="0.01" id="yourPriceInput" class="form-control" value="${Math.round(priceV
 5330                  </div>
 5331                  <div class="col-md-6 mb-3">
 5332                    <label for="oldPriceInput" class="form-label">Цена до скидки, ₽</label>
 5333                    <input type="number" step="0.01" id="oldPriceInput" class="form-control" value="${Math.round(oldPric
 5334                  </div>
 5335                </div>
 5336                <div class="row">
 5337                  <div class="col-md-6 mb-3">
 5338                    <label for="minPriceInput" class="form-label">Минимальная цена, ₽</label>
 5339                    <input type="number" step="0.01" id="minPriceInput" class="form-control" value="${Math.round(priceVa
 5340                  </div>
 5341                  <div class="col-md-6 mb-3">
 5342                    <label for="costPriceInput" class="form-label">Себестоимость, ₽</label>
 5343                    <input type="number" step="0.01" id="costPriceInput" class="form-control" placeholder="0.00" />
 5344                  </div>
 5345                </div>`;
 5346            }
 5347
 5348            const modal = new bootstrap.Modal(document.getElementById('setPriceModal'));
 5349            modal.show();
 5350
 5351            // Функция для перерасчета цены до скидки
 5352            function recalculateOldPrice() {
 5353                const yourPrice = parseFloat(document.getElementById('yourPriceInput').value);
 5354                if (!isNaN(yourPrice) && yourPrice > 0) {
 5355                    const oldPriceValue = yourPrice * 1.25; // +25%
 5356                    document.getElementById('oldPriceInput').value = Math.round(oldPriceValue);
 5357                    document.getElementById('minPriceInput').value = Math.round(yourPrice);
 5358                }
 5359            }
 5360
 5361            // Обработчик изменения поля "Ваша цена"
 5362            document.getElementById('yourPriceInput').addEventListener('input', recalculateOldPrice);
 5363            document.getElementById('yourPriceInput').addEventListener('change', recalculateOldPrice);
 5364
 5365            // Обработка кнопки "Установить цены"
 5366            document.getElementById('confirmSetPrice').onclick = async function () {
 5367                const yourPrice = parseFloat(document.getElementById('yourPriceInput').value);
 5368                const oldPrice = parseFloat(document.getElementById('oldPriceInput').value);
 5369                const minPrice = parseFloat(document.getElementById('minPriceInput').value);
 5370                const costPrice = parseFloat(document.getElementById('costPriceInput').value);
 5371
 5372                // Валидация - хотя бы одна цена должна быть заполнена
 5373                if (isNaN(yourPrice) && isNaN(oldPrice) && isNaN(minPrice) && isNaN(costPrice)) {
 5374                    St2Dialog.alert('Введите хотя бы одну цену', { variant: 'warning' });
 5375                    return;
 5376                }
 5377
 5378                modal.hide();
 5379
 5380                $("#ozonTableContainer").html(`
 5381                <div class="d-flex justify-content-center align-items-center" style="height:100px;">
 5382                    <div class="spinner-border text-primary" role="status">
 5383                        <span class="visually-hidden">Загрузка Ozon...</span>
 5384                    </div>
 5385                </div>
 5386            `);
 5387
 5388                try {
 5389                    const response = await fetch('/BitrixStock/SetProductPrice', {
 5390                        method: 'POST',
 5391                        headers: {'Content-Type': 'application/json'},
 5392                        body: JSON.stringify({
 5393                            article: offerId,
 5394                            clientName: clientName,
 5395                            yourPrice: isNaN(yourPrice) ? 0 : yourPrice,
 5396                            oldPrice: isNaN(oldPrice) ? 0 : oldPrice,
 5397                            minPrice: isNaN(minPrice) ? 0 : minPrice,
 5398                            costPrice: isNaN(costPrice) ? 0 : costPrice
 5399                        })
 5400                    });
 5401
 5402                    const data = await response.json();
 5403                    await reloadOzonPanel(offerId, null, clientName);
 5404
 5405                    if (response.ok) {
 5406                        data.result.forEach(offer => {
 5407                            showNotification(
 5408                                `Цены для товара ${offerId} успешно установлены<br/>${offer.message}`,
 5409                                'success'
 5410                            );
 5411                        });
 5412
 5413                    } else {
 5414                        showNotification(data.message ?? 'Ошибка при установке цен', 'danger');
 5415                    }
 5416                } catch (error) {
 5417                    console.error(error);
 5418                    showNotification('Ошибка при отправке данных на сервер', 'danger');
 5419                }
 5420            };
 5421        }
 5422    });
 5423
 5424    function recalculateOldPrice() {
 5425        const yourPrice = parseFloat(document.getElementById('yourPriceInput').value);
 5426        if (!isNaN(yourPrice) && yourPrice > 0) {
 5427            const oldPriceValue = yourPrice * 1.25; // +25%
 5428            document.getElementById('oldPriceInput').value = oldPriceValue.toFixed(2);
 5429        }
 5430    }
 5431
 5432    document.addEventListener('input', function (e) {
 5433        if (e.target.matches('.constant-index') || e.target.matches('.percent')) {
 5434            const link = e.target.closest('.update_price');
 5435            const constantIndex = parseFloat(link.querySelector('.constant-index').innerText.replace(',', '.')) || 0;
 5436            const percent = parseFloat(link.querySelector('.percent').innerText.replace(',', '.')) || 0;
 5437
 5438            // обновляем data-атрибуты
 5439            link.setAttribute('data-constant-index', constantIndex.toFixed(2));
 5440            link.setAttribute('data-percent', percent.toFixed(1));
 5441            updateUpdatePriceHint(link);
 5442        }
 5443    });
 5444
 5445    document.addEventListener('mouseover', function (e) {
 5446        const link = e.target.closest('.update_price');
 5447        if (link) {
 5448            updateUpdatePriceHint(link);
 5449        }
 5450    });
 5451
 5452    document.addEventListener('focusin', function (e) {
 5453        const link = e.target.closest('.update_price');
 5454        if (link) {
 5455            updateUpdatePriceHint(link);
 5456        }
 5457    });
 5458
 5459    document.addEventListener('click', function (e) {
 5460        if (e.target.classList.contains('weight-value')) {
 5461            let weight = e.target.getAttribute('data-weight') || '';
 5462            const orderId = e.target.getAttribute('data-order-id');
 5463            const weightInput = document.querySelector(`#ProductWeight_${orderId}`);
 5464
 5465            if (weightInput) {
 5466                // Очистка строки: убираем "кг" и пробелы
 5467                weight = weight.replace(/кг/gi, '').trim();
 5468
 5469                // Заменяем запятые на точки
 5470                weight = weight.replace(/,/g, '.');
 5471
 5472                // Берём первое число из строки (если несколько значений через запятую/пробел)
 5473                const match = weight.match(/[\d.]+/g);
 5474                const firstWeight = match ? match[0] : null;
 5475
 5476                if (firstWeight) {
 5477                    // Форматируем вес (parseFloat убирает лишние нули)
 5478                    const formattedWeight = parseFloat(firstWeight).toString();
 5479
 5480                    // Устанавливаем значение
 5481                    weightInput.value = formattedWeight;
 5482
 5483                    // Триггерим события
 5484                    const eventsToTrigger = ['input', 'change', 'blur'];
 5485                    eventsToTrigger.forEach(eventType => {
 5486                        const event = new Event(eventType, {bubbles: true, cancelable: true});
 5487                        weightInput.dispatchEvent(event);
 5488                    });
 5489
 5490                    // Для jQuery (если используется)
 5491                    if (typeof jQuery !== 'undefined') {
 5492                        $(weightInput).trigger('input').trigger('change');
 5493                    }
 5494
 5495                    // Подсвечиваем поле на 1 секунду
 5496
 5497                    weightInput.style.backgroundColor = '#e6ffe6';
 5498                    setTimeout(() => {
 5499                        weightInput.style.backgroundColor = '';
 5500                    }, 1000);
 5501
 5502                    // Фокусируем поле ввода
 5503                    weightInput.focus();
 5504                } else {
 5505                    console.warn(`Не удалось распарсить вес: "${weight}"`);
 5506                }
 5507
 5508                sortAndRerenderTable(orderId, 'costPrice', 'number', 'acs');
 5509            } else {
 5510                console.warn(`Не найдено поле ввода веса для orderId: ${orderId}`);
 5511            }
 5512        }
 5513    });
 5514
 5515    function applyBitrixActiveUiAfterToggle(activeEl, isActive) {
 5516        const display = isActive ? 'Да' : 'Нет';
 5517        activeEl.textContent = display;
 5518        activeEl.style.opacity = '1';
 5519        activeEl.classList.toggle('text-danger', !isActive);
 5520
 5521        const row = activeEl.closest('.bitrix-stock-row');
 5522        if (!row) {
 5523            return;
 5524        }
 5525
 5526        row.setAttribute('data-bitrix-active', isActive ? 'Y' : 'N');
 5527        row.querySelectorAll('.refreshOzonMain').forEach(btn => {
 5528            btn.style.display = isActive ? '' : 'none';
 5529        });
 5530        row.querySelectorAll('.clearOzonStock').forEach(btn => {
 5531            btn.style.display = !isActive ? '' : 'none';
 5532        });
 5533    }
 5534
 5535    document.addEventListener('DOMContentLoaded', function () {
 5536        document.querySelectorAll('[data-active-id]').forEach(element => {
 5537            element.addEventListener('click', async function (e) {
 5538                e.preventDefault();
 5539
 5540                const productId = this.getAttribute('data-active-id');
 5541                const currentStatus = this.textContent.trim();
 5542                const wasActive = currentStatus === 'Да';
 5543
 5544                const confirmationMessage = wasActive
 5545                    ? 'Вы уверены, что хотите деактивировать этот элемент? Остатки Ozon не изменятся автоматически.'
 5546                    : 'Вы уверены, что хотите активировать этот элемент? Остатки Ozon не изменятся автоматически.';
 5547
 5548                const confirmed = await St2Dialog.confirm(confirmationMessage);
 5549                if (!confirmed) {
 5550                    return;
 5551                }
 5552
 5553                const originalText = this.textContent;
 5554                this.textContent = 'Загрузка...';
 5555                this.style.opacity = '0.6';
 5556
 05557                fetch('@Url.Action("ToggleActiveStatus", "BitrixStock", new { area = "Studio2" })', {
 5558                    method: 'POST',
 5559                    headers: {
 5560                        'Content-Type': 'application/json',
 5561                    },
 5562                    body: JSON.stringify(parseInt(productId))
 5563                })
 5564                    .then(response => response.json())
 5565                    .then(data => {
 5566                        if (data.success) {
 5567                            showNotification(
 5568                                (data.message || 'Статус обновлён') +
 5569                                ' Накат/обнуление Ozon — только кнопками в таблице.',
 5570                                'success');
 5571                            applyBitrixActiveUiAfterToggle(this, !!data.isActive);
 5572                        } else {
 5573                            showNotification(data.message || 'Произошла ошибка', 'danger');
 5574                            this.textContent = originalText;
 5575                            this.style.opacity = '1';
 5576                        }
 5577                    })
 5578                    .catch(error => {
 5579                        console.error('Error:', error);
 5580                        showNotification('Ошибка сети', 'danger');
 5581                        this.textContent = originalText;
 5582                        this.style.opacity = '1';
 5583                    });
 5584            });
 5585        });
 5586    });
 5587
 5588    $(document).on('click', '.refreshOzon', async function () {
 5589        const offerId = normalizeOfferId($(this).data("article"));
 5590        if (!offerId) {
 5591            console.warn('Offer ID не найден');
 5592            return;
 5593        }
 5594
 5595        // Показываем спиннер
 5596        $("#ozonTableContainer").html(`
 5597        <div class="d-flex justify-content-center align-items-center" style="height:100px;">
 5598            <div class="spinner-border text-primary" role="status">
 5599                <span class="visually-hidden">Загрузка Ozon...</span>
 5600            </div>
 5601        </div>
 5602    `);
 5603
 5604        try {
 5605            await reloadOzonPanel(offerId);
 5606        } catch (error) {
 5607            console.error('Ошибка при обновлении данных Ozon:', error);
 5608            $("#ozonTableContainer").empty();
 5609        }
 5610    });
 5611
 5612    const triggerAllClearOzonStockBtn = document.getElementById('triggerAllClearOzonStock');
 5613    if (triggerAllClearOzonStockBtn) {
 5614        triggerAllClearOzonStockBtn.addEventListener('click', function () {
 5615        // Находим все кнопки обнуления
 5616        const clearButtons = document.querySelectorAll('.clearOzonStock');
 5617
 5618        // Создаем событие правого клика мыши
 5619        const rightClickEvent = new MouseEvent('contextmenu', {
 5620            bubbles: true,
 5621            cancelable: true,
 5622            view: window,
 5623            button: 2, // Правая кнопка мыши
 5624            buttons: 2
 5625        });
 5626
 5627        // Инициируем правый клик на каждой кнопке
 5628        clearButtons.forEach(button => {
 5629            button.dispatchEvent(rightClickEvent);
 5630        });
 5631        showNotification(`Выделено ${clearButtons.length} позиций для обнуления`, 'success');
 5632        });
 5633    }
 5634
 5635    // Обработчик для кнопки массового обновления
 5636    const triggerAllRefreshOzonStockBtn = document.getElementById('triggerAllRefreshOzonStock');
 5637    if (triggerAllRefreshOzonStockBtn) {
 5638        triggerAllRefreshOzonStockBtn.addEventListener('click', function () {
 5639        // Находим все кнопки обновления
 5640        const refreshButtons = document.querySelectorAll('.refreshOzonMain');
 5641
 5642        // Создаем событие правого клика мыши
 5643        const rightClickEvent = new MouseEvent('contextmenu', {
 5644            bubbles: true,
 5645            cancelable: true,
 5646            view: window,
 5647            button: 2, // Правая кнопка мыши
 5648            buttons: 2
 5649        });
 5650
 5651        // Инициируем правый клик на каждой кнопке обновления
 5652        refreshButtons.forEach(button => {
 5653            button.dispatchEvent(rightClickEvent);
 5654        });
 5655
 5656        showNotification(`Выделено ${refreshButtons.length} позиций для обновления`, 'success');
 5657        });
 5658    }
 5659
 5660    // Функция для проверки и отображения массовых кнопок
 5661    function updateMassActionButtons() {
 5662        const refreshButtons = document.querySelectorAll('.refreshOzonMain');
 5663        const clearButtons = document.querySelectorAll('.clearOzonStock');
 5664
 5665        const refreshMassButton = document.getElementById('triggerAllRefreshOzonStock');
 5666        const clearMassButton = document.getElementById('triggerAllClearOzonStock');
 5667
 5668        if (refreshMassButton) {
 5669            refreshMassButton.style.display = refreshButtons.length > 0 ? 'block' : 'none';
 5670        }
 5671
 5672        if (clearMassButton) {
 5673            clearMassButton.style.display = clearButtons.length > 0 ? 'block' : 'none';
 5674        }
 5675
 5676        console.log(`На странице: ${refreshButtons.length} кнопок обновления, ${clearButtons.length} кнопок обнуления`);
 5677    }
 5678
 5679    // Вызываем при загрузке страницы
 5680    document.addEventListener('DOMContentLoaded', function () {
 5681        updateMassActionButtons();
 5682    });
 5683
 5684    // Также можно вызывать после фильтрации или обновления данных
 5685    function handleDataUpdate() {
 5686        setTimeout(updateMassActionButtons, 100); // Небольшая задержка для обновления DOM
 5687    }
 5688
 5689    // Показать спиннер загрузки
 5690    function showLoadingSpinner(message) {
 5691        const spinner = document.getElementById('loadingSpinner');
 5692        const spinnerText = document.getElementById('loadingSpinnerText');
 5693        if (spinner) {
 5694            spinner.style.display = 'flex';
 5695            if (spinnerText) {
 5696                spinnerText.textContent = message || 'Загрузка...';
 5697            }
 5698
 5699            // Блокируем страницу
 5700            document.body.classList.add('spinner-active');
 5701
 5702            // Предотвращаем закрытие спиннера кликом на фон (опционально)
 5703            spinner.addEventListener('click', function (e) {
 5704                e.stopPropagation();
 5705            });
 5706        }
 5707    }
 5708
 5709    // Скрыть спиннер загрузки
 5710    function hideLoadingSpinner() {
 5711        const spinner = document.getElementById('loadingSpinner');
 5712        if (spinner) {
 5713            spinner.style.display = 'none';
 5714
 5715            // Разблокируем страницу
 5716            document.body.classList.remove('spinner-active');
 5717        }
 5718    }
 5719
 5720    // Отправка формы фильтров перехвачена гридом «Остатки» (bsx-stocks.js): страница не перезагружается.
 5721</script>
 5722
 5723
 5724<script>
 5725    // ========== Функционал сохранения и применения фильтров ==========
 5726
 5727    document.addEventListener('DOMContentLoaded', function () {
 5728        console.log('Инициализация функционала фильтров...');
 5729
 5730        // Проверяем наличие необходимых элементов
 5731        console.log('saveFilterBtn:', document.getElementById('saveFilterBtn'));
 5732        console.log('saveFilterModal:', document.getElementById('saveFilterModal'));
 5733        console.log('savedFiltersSelect:', document.getElementById('savedFiltersSelect'));
 5734
 5735        // Функция для получения текущих параметров фильтра
 5736        function getCurrentFilterParams() {
 5737            try {
 5738                const searchParams = new URLSearchParams(window.location.search);
 5739
 5740                // Добавляем параметры из формы фильтрации
 5741                const filterForm = document.getElementById('filterForm');
 5742                if (filterForm) {
 5743                    const formData = new FormData(filterForm);
 5744                    formData.forEach((value, key) => {
 5745                        if (value && value !== '') {
 5746                            searchParams.set(key, value);
 5747                        }
 5748                    });
 5749                }
 5750
 5751                // Очищаем стандартные параметры пагинации
 5752                searchParams.delete('page');
 5753                searchParams.delete('pageSize');
 5754
 5755                // Преобразуем в строку
 5756                const paramsString = searchParams.toString();
 5757                console.log('Строка параметров фильтра:', paramsString);
 5758
 5759                return paramsString;
 5760            } catch (error) {
 5761                console.error('Ошибка при получении параметров фильтра:', error);
 5762                return '';
 5763            }
 5764        }
 5765
 5766        // Функция для показа уведомлений
 5767        function showNotification(message, type = 'info') {
 5768            // Если у вас уже есть функция showNotification, можете использовать её
 5769            // Или создайте простую реализацию
 5770            console.log(`${type}: ${message}`);
 5771
 5772            // Пример простой реализации
 5773            const notification = document.createElement('div');
 5774            notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
 5775            notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999;';
 5776            notification.innerHTML = `
 5777            ${message}
 5778            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
 5779        `;
 5780
 5781            document.body.appendChild(notification);
 5782
 5783            // Автоматическое скрытие через 5 секунд
 5784            setTimeout(() => {
 5785                if (notification.parentNode) {
 5786                    notification.parentNode.removeChild(notification);
 5787                }
 5788            }, 5000);
 5789        }
 5790
 5791        // ========== Обработчик кнопки сохранения фильтра ==========
 5792        const saveFilterBtn = document.getElementById('saveFilterBtn');
 5793        if (saveFilterBtn) {
 5794            saveFilterBtn.addEventListener('click', function () {
 5795                console.log('Кнопка сохранения фильтра нажата');
 5796                const filterData = getCurrentFilterParams();
 5797                console.log('Собранные параметры фильтра:', filterData);
 5798
 5799                if (!filterData) {
 5800                    showNotification('Нет параметров для сохранения', 'warning');
 5801                    return;
 5802                }
 5803
 5804                const modalElement = document.getElementById('saveFilterModal');
 5805                if (!modalElement) {
 5806                    console.error('Модальное окно не найдено');
 5807                    return;
 5808                }
 5809
 5810                // Очищаем поле ввода и скрываем ошибку
 5811                const filterNameInput = document.getElementById('filterNameInput');
 5812                const filterNameError = document.getElementById('filterNameError');
 5813
 5814                if (filterNameInput) {
 5815                    filterNameInput.value = '';
 5816                    filterNameInput.focus();
 5817                }
 5818                if (filterNameError) {
 5819                    filterNameError.style.display = 'none';
 5820                }
 5821
 5822                // Показываем модальное окно
 5823                try {
 5824                    // Используем существующий экземпляр или создаем новый
 5825                    let modal = bootstrap.Modal.getInstance(modalElement);
 5826                    if (!modal) {
 5827                        modal = new bootstrap.Modal(modalElement);
 5828                    }
 5829                    modal.show();
 5830                } catch (error) {
 5831                    console.error('Ошибка при открытии модального окна:', error);
 5832                    // Альтернативный способ открытия
 5833                    modalElement.style.display = 'block';
 5834                    modalElement.classList.add('show');
 5835                    document.body.classList.add('modal-open');
 5836                }
 5837            });
 5838        } else {
 5839            console.warn('Кнопка saveFilterBtn не найдена');
 5840        }
 5841
 5842        // ========== Обработчик подтверждения сохранения фильтра ==========
 5843        const confirmSaveFilterBtn = document.getElementById('confirmSaveFilterBtn');
 5844        if (confirmSaveFilterBtn) {
 5845            confirmSaveFilterBtn.addEventListener('click', async function () {
 5846                const filterNameInput = document.getElementById('filterNameInput');
 5847                const filterName = filterNameInput ? filterNameInput.value.trim() : '';
 5848                const errorElement = document.getElementById('filterNameError');
 5849
 5850                if (!filterName) {
 5851                    if (errorElement) {
 5852                        errorElement.textContent = 'Введите название фильтра';
 5853                        errorElement.style.display = 'block';
 5854                        filterNameInput?.focus();
 5855                    }
 5856                    return;
 5857                }
 5858
 5859                if (errorElement) {
 5860                    errorElement.style.display = 'none';
 5861                }
 5862
 5863                const filterData = getCurrentFilterParams();
 5864                console.log('Сохранение фильтра:', {Name: filterName, FilterData: filterData});
 5865
 5866                try {
 5867                    const saveUrl = '/Studio2/BitrixStock/SaveFilter';
 5868                    console.log('URL для сохранения:', saveUrl);
 5869
 5870                    // Получаем CSRF токен, если используется
 5871                    const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
 5872
 5873                    const headers = {
 5874                        'Content-Type': 'application/json',
 5875                    };
 5876
 5877                    if (token) {
 5878                        headers['RequestVerificationToken'] = token;
 5879                    }
 5880
 5881                    const response = await fetch(saveUrl, {
 5882                        method: 'POST',
 5883                        headers: headers,
 5884                        body: JSON.stringify({
 5885                            Name: filterName,
 5886                            FilterData: filterData || ''
 5887                        })
 5888                    });
 5889
 5890                    console.log('Ответ сервера:', response.status, response.statusText);
 5891
 5892                    if (!response.ok) {
 5893                        const errorText = await response.text();
 5894                        console.error('Ошибка ответа:', errorText);
 5895                        showNotification('Ошибка сервера: ' + response.status, 'danger');
 5896                        return;
 5897                    }
 5898
 5899                    const result = await response.json();
 5900                    console.log('Результат сохранения:', result);
 5901
 5902                    if (result.success) {
 5903                        showNotification(result.message || 'Фильтр успешно сохранен', 'success');
 5904
 5905                        // Закрываем модальное окно
 5906                        const modalElement = document.getElementById('saveFilterModal');
 5907                        if (modalElement) {
 5908                            const modal = bootstrap.Modal.getInstance(modalElement);
 5909                            if (modal) {
 5910                                modal.hide();
 5911                            }
 5912                        }
 5913
 5914                        // Обновляем список сохраненных фильтров
 5915                        setTimeout(() => {
 5916                            window.location.reload();
 5917                        }, 1000);
 5918                    } else {
 5919                        showNotification(result.message || 'Ошибка при сохранении фильтра', 'danger');
 5920                    }
 5921                } catch (error) {
 5922                    console.error('Ошибка при сохранении фильтра:', error);
 5923                    showNotification('Ошибка при сохранении фильтра: ' + error.message, 'danger');
 5924                }
 5925            });
 5926        } else {
 5927            console.warn('Кнопка confirmSaveFilterBtn не найдена');
 5928        }
 5929
 5930        // ========== Обработчик Enter в поле имени фильтра ==========
 5931        const filterNameInput = document.getElementById('filterNameInput');
 5932        if (filterNameInput) {
 5933            filterNameInput.addEventListener('keypress', function (e) {
 5934                if (e.key === 'Enter') {
 5935                    e.preventDefault();
 5936                    const confirmBtn = document.getElementById('confirmSaveFilterBtn');
 5937                    if (confirmBtn) {
 5938                        confirmBtn.click();
 5939                    }
 5940                }
 5941            });
 5942        }
 5943
 5944        // ========== Обработчики для работы с сохраненными фильтрами ==========
 5945        const savedFiltersSelect = document.getElementById('savedFiltersSelect');
 5946        const applyFilterBtn = document.getElementById('applyFilterBtn');
 5947        const deleteFilterBtn = document.getElementById('deleteFilterBtn');
 5948
 5949        if (savedFiltersSelect && applyFilterBtn && deleteFilterBtn) {
 5950            // Инициализируем состояние кнопок
 5951            const hasSelection = savedFiltersSelect.value !== '';
 5952            applyFilterBtn.disabled = !hasSelection;
 5953            deleteFilterBtn.disabled = !hasSelection;
 5954
 5955            // Обработчик изменения выбранного фильтра
 5956            savedFiltersSelect.addEventListener('change', function () {
 5957                const hasSelection = this.value !== '';
 5958                applyFilterBtn.disabled = !hasSelection;
 5959                deleteFilterBtn.disabled = !hasSelection;
 5960            });
 5961
 5962            // Обработчик применения фильтра
 5963            applyFilterBtn.addEventListener('click', function () {
 5964                const selectedOption = savedFiltersSelect.options[savedFiltersSelect.selectedIndex];
 5965                let filterData = selectedOption.getAttribute('data-filter-data');
 5966
 5967                if (!filterData) {
 5968                    showNotification('Ошибка: данные фильтра не найдены', 'danger');
 5969                    return;
 5970                }
 5971
 5972                // Исправляем HTML-кодирование (&amp; -> &), чтобы параметры корректно передавались в URL
 5973                filterData = filterData.replace(/&amp;/g, '&');
 5974
 5975                // Перенаправляем на страницу с параметрами фильтра
 5976                const baseUrl = '/Studio2/BitrixStock';
 5977                const url = filterData ? `${baseUrl}?${filterData}` : baseUrl;
 5978                window.location.href = url;
 5979            });
 5980
 5981            // Обработчик удаления фильтра
 5982            deleteFilterBtn.addEventListener('click', async function () {
 5983                const filterId = savedFiltersSelect.value;
 5984
 5985                if (!filterId) {
 5986                    showNotification('Выберите фильтр для удаления', 'warning');
 5987                    return;
 5988                }
 5989
 5990                const selectedOption = savedFiltersSelect.options[savedFiltersSelect.selectedIndex];
 5991                const filterName = selectedOption.text;
 5992
 5993                if (!(await St2Dialog.confirm(`Вы уверены, что хотите удалить фильтр "${filterName}"?`, { variant: 'dang
 5994                    return;
 5995                }
 5996
 5997                try {
 5998                    const deleteUrl = `/Studio2/BitrixStock/DeleteFilter?id=${filterId}`;
 5999
 6000                    // Получаем CSRF токен, если используется
 6001                    const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
 6002
 6003                    const headers = {
 6004                        'Content-Type': 'application/json',
 6005                    };
 6006
 6007                    if (token) {
 6008                        headers['RequestVerificationToken'] = token;
 6009                    }
 6010
 6011                    const response = await fetch(deleteUrl, {
 6012                        method: 'POST',
 6013                        headers: headers
 6014                    });
 6015
 6016                    const result = await response.json();
 6017
 6018                    if (result.success) {
 6019                        showNotification(result.message || 'Фильтр успешно удален', 'success');
 6020
 6021                        // Обновляем список сохраненных фильтров
 6022                        setTimeout(() => {
 6023                            window.location.reload();
 6024                        }, 1000);
 6025                    } else {
 6026                        showNotification(result.message || 'Ошибка при удалении фильтра', 'danger');
 6027                    }
 6028                } catch (error) {
 6029                    console.error('Ошибка при удалении фильтра:', error);
 6030                    showNotification('Ошибка при удалении фильтра: ' + error.message, 'danger');
 6031                }
 6032            });
 6033        } else {
 6034            console.warn('Элементы для работы с сохраненными фильтрами не найдены');
 6035        }
 6036
 6037        // ========== Обработчик закрытия модального окна по ESC ==========
 6038        document.addEventListener('keydown', function (e) {
 6039            if (e.key === 'Escape') {
 6040                const modalElement = document.getElementById('saveFilterModal');
 6041                if (modalElement && modalElement.classList.contains('show')) {
 6042                    const modal = bootstrap.Modal.getInstance(modalElement);
 6043                    if (modal) {
 6044                        modal.hide();
 6045                    }
 6046                }
 6047            }
 6048        });
 6049
 6050        console.log('Функционал фильтров инициализирован');
 6051    });
 6052
 6053    // Экспорт функций для использования в других скриптах (если нужно)
 6054    if (typeof module !== 'undefined' && module.exports) {
 6055        module.exports = {
 6056            getCurrentFilterParams,
 6057            showNotification
 6058        };
 6059    }
 6060</script>
 6061}
 6062
 6063@functions {
 6064
 6065
 6066}