< Summary

Information
Class: AspNetCoreGeneratedDocument.Areas_Studio2_Views_BitrixStock__SyncReportScript
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/BitrixStock/_SyncReportScript.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 74
Coverable lines: 74
Total lines: 5338
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()100%210%

File(s)

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

#LineLine coverage
 1@{
 02    var bitrixStockBaseUrl = Url.Action("Index", "BitrixStock", new { area = "Studio2" })!;
 03    var syncStartUrl = Url.Action("StartSyncReport", "BitrixStock", new { area = "Studio2" })!;
 04    var syncReportsListUrl = Url.Action("ListSyncReports", "BitrixStock", new { area = "Studio2" })!;
 05    var syncReportGetUrlTemplate = Url.Action("GetSyncReport", "BitrixStock", new { area = "Studio2", reportId = "__REPO
 06    var syncReportOneCStoresUrlTemplate = Url.Action("GetSyncReportOneCStores", "BitrixStock", new { area = "Studio2", r
 07    var syncReportApplyStartUrlTemplate = Url.Action("StartSyncReportApply", "BitrixStock", new { area = "Studio2", repo
 08    var syncReportExcelUrlTemplate = Url.Action("ExportSyncReportExcel", "BitrixStock", new { area = "Studio2", reportId
 09    var syncReportExcelTaskStartUrlTemplate = Url.Action("StartSyncReportExcelExportTask", "BitrixStock", new { area = "
 010    var syncReportExcelTaskStatusUrlTemplate = Url.Action("GetSyncReportExcelExportTaskStatus", "BitrixStock", new { are
 011    var syncReportExcelTaskDownloadUrlTemplate = Url.Action("DownloadSyncReportExcelExportTaskResult", "BitrixStock", ne
 012    var syncReportProductNamesUrlTemplate = Url.Action("GetSyncReportProductNames", "BitrixStock", new { area = "Studio2
 013    var syncReportSendEmailUrlTemplate = Url.Action("SendSyncReportEmail", "BitrixStock", new { area = "Studio2", report
 014    var warehouseMappingsUrlTemplate = Url.Action("GetWarehouseMappingsForClient", "BitrixStock", new { area = "Studio2"
 015    var syncPresetsUrl = Url.Action("GetSyncPresets", "BitrixStock", new { area = "Studio2" })!;
 016    var syncPresetsCreateUrl = Url.Action("CreateSyncPreset", "BitrixStock", new { area = "Studio2" })!;
 017    var syncPresetsBaseUrl = Url.Action("GetSyncPresets", "BitrixStock", new { area = "Studio2" })!;
 018    var syncReportUnarchiveUrlTemplate = Url.Action("UnarchiveReportProducts", "BitrixStock", new { area = "Studio2", re
 019    var syncReportArchiveUrlTemplate = Url.Action("ArchiveReportProducts", "BitrixStock", new { area = "Studio2", report
 020    var syncApplyLogUrlTemplate = Url.Action("GetApplyArticleLog", "BitrixStock", new { area = "Studio2", reportId = "__
 021    var syncApplyLogJobsUrlTemplate = Url.Action("GetApplyLogJobs", "BitrixStock", new { area = "Studio2", reportId = "_
 022    var syncArchivePreviewUrlTemplate = Url.Action("GetArchivedPreview", "BitrixStock", new { area = "Studio2", reportId
 023    var syncReportLogUrlTemplate = Url.Action("DownloadSyncReportLog", "BitrixStock", new { area = "Studio2", reportId =
 024    var syncReportMappingCombosUrlTemplate = Url.Action("GetSyncReportMappingCombos", "BitrixStock", new { area = "Studi
 025    var syncReportCleanupPreviewUrlTemplate = Url.Action("GetCleanupPreview", "BitrixStock", new { area = "Studio2", rep
 026    var syncReportCleanupStartUrlTemplate = Url.Action("StartCleanup", "BitrixStock", new { area = "Studio2", reportId =
 027    var syncReportPricePushStartUrlTemplate = Url.Action("StartSyncReportPricePush", "BitrixStock", new { area = "Studio
 028    var syncReportCleanupZeroUrlTemplate = Url.Action("ZeroCleanupWarehouse", "BitrixStock", new { area = "Studio2", rep
 029    var syncReportPromosUrlTemplate = Url.Action("GetSyncReportPromos", "BitrixStock", new { area = "Studio2", reportId 
 030    var syncReportPriceSettingsUrlTemplate = Url.Action("GetSyncReportPriceSettings", "BitrixStock", new { area = "Studi
 031    var articleC1PriceUrlTemplate = Url.Action("GetArticleC1Price", "BitrixStock", new { area = "Studio2", article = "__
 032    var priceOptimizationUrlTemplate = Url.Action("GetPriceOptimization", "BitrixStock", new { area = "Studio2", reportI
 033    var orderReserveMapUrl = Url.Action("GetOrderReserveMap", "BitrixStock", new { area = "Studio2" })!;
 034    var orderReserveMapExcelUrl = Url.Action("ExportOrderReserveMapExcel", "BitrixStock", new { area = "Studio2" })!;
 035    var ozonOrphanPreviewUrlTemplate = Url.Action("GetOzonOrphanPreview", "BitrixStock", new { area = "Studio2", reportI
 036    var ozonOrphanStartUrlTemplate = Url.Action("StartOzonOrphanCleanup", "BitrixStock", new { area = "Studio2", reportI
 037    var ozonFilesUrlTemplate = Url.Action("GetOzonReportFiles", "BitrixStock", new { area = "Studio2", reportId = "__REP
 038    var ozonFileDownloadUrlTemplate = Url.Action("DownloadOzonReportFile", "BitrixStock", new { area = "Studio2", report
 39}
 40<script>
 41(function () {
 042    const bitrixStockBaseUrl = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(bitrixStockBaseUrl));
 43
 44    function normalizeBitrixStockApiUrl(url) {
 45        if (!url) {
 46            return url;
 47        }
 48        try {
 49            const normalizedBase = String(bitrixStockBaseUrl || '/Studio2/BitrixStock').replace(/\/+$/, '');
 50            const parsed = new URL(url, window.location.origin);
 51            if (parsed.origin === window.location.origin && parsed.pathname.startsWith('/sync-reports')) {
 52                return `${normalizedBase}${parsed.pathname}${parsed.search}${parsed.hash}`;
 53            }
 54        } catch (_) {
 55            // если URL не парсится, возвращаем как есть
 56        }
 57        return url;
 58    }
 59
 60    function buildSyncReportsUrlCandidates(url) {
 61        const candidates = [];
 62        if (!url) {
 63            return candidates;
 64        }
 65        try {
 66            const parsed = new URL(url, window.location.origin);
 67            const search = parsed.search || '';
 68            const hash = parsed.hash || '';
 69            const pathname = parsed.pathname || '';
 70            const push = (path) => {
 71                const normalizedPath = (path || '').trim();
 72                if (!normalizedPath) {
 73                    return;
 74                }
 75                const value = `${normalizedPath}${search}${hash}`;
 76                if (!candidates.includes(value)) {
 77                    candidates.push(value);
 78                }
 79            };
 80
 81            push(pathname);
 82            if (pathname.includes('/sync-reports')) {
 83                push(pathname.replace(/^\/Studio2\/BitrixStock\/sync-reports/, '/sync-reports'));
 84                push(pathname.replace(/^\/BitrixStock\/sync-reports/, '/sync-reports'));
 85                push(pathname.replace(/^\/sync-reports/, '/BitrixStock/sync-reports'));
 86                push(pathname.replace(/^\/sync-reports/, '/Studio2/BitrixStock/sync-reports'));
 87            }
 88        } catch (_) {
 89            candidates.push(url);
 90        }
 91        return candidates;
 92    }
 93
 94    async function fetchWithSyncReportsFallback(url, options) {
 95        const candidates = buildSyncReportsUrlCandidates(url);
 96        if (!candidates.length) {
 97            return fetch(url, options);
 98        }
 99
 100        let lastResponse = null;
 101        for (let i = 0; i < candidates.length; i++) {
 102            const candidate = candidates[i];
 103            const response = await fetch(candidate, options);
 104            if (response.status !== 404) {
 105                return response;
 106            }
 107            lastResponse = response;
 108        }
 109        return lastResponse ?? fetch(url, options);
 110    }
 111
 0112    const syncStartUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncStartUrl)));
 0113    const syncReportsListUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncReport
 0114    const syncReportGetUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(sync
 0115    const syncReportOneCStoresUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seriali
 0116    const syncReportApplyStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seriali
 0117    const syncReportExcelUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(sy
 0118    const syncReportExcelTaskStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Ser
 0119    const syncReportExcelTaskStatusUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Se
 0120    const syncReportExcelTaskDownloadUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.
 0121    const syncReportProductNamesUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seria
 0122    const syncReportSendEmailUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serializ
 0123    const warehouseMappingsUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(
 0124    const syncPresetsUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncPresetsUrl
 0125    const syncPresetsCreateUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncPres
 0126    const syncPresetsBaseUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncPreset
 0127    const syncReportUnarchiveUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serializ
 0128    const syncReportArchiveUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(
 0129    const syncApplyLogUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncA
 0130    const syncApplyLogJobsUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(s
 0131    const syncArchivePreviewUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize
 0132    const syncReportLogUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(sync
 0133    const syncReportMappingCombosUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seri
 0134    const syncReportCleanupPreviewUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Ser
 0135    const syncReportCleanupStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seria
 0136    const syncReportPricePushStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Ser
 0137    const syncReportCleanupZeroUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serial
 0138    const syncReportPromosUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(s
 0139    const syncReportPriceSettingsUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seri
 0140    const articleC1PriceUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(art
 0141    const priceOptimizationUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(
 0142    const orderReserveMapUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(orderReser
 0143    const orderReserveMapExcelUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(order
 0144    const ozonOrphanPreviewUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(
 0145    const ozonOrphanStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(oz
 0146    const ozonFilesUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ozonFile
 0147    const ozonFileDownloadUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(o
 148
 149    const ozonOrphanPreviewUrl = id => ozonOrphanPreviewUrlTemplate.replace('__REPORTID__', id);
 150    const ozonOrphanStartUrl   = id => ozonOrphanStartUrlTemplate.replace('__REPORTID__', id);
 151    const ozonFilesUrl         = id => ozonFilesUrlTemplate.replace('__REPORTID__', id);
 152    const ozonFileDownloadUrl  = (reportId, fileId) =>
 153        ozonFileDownloadUrlTemplate.replace('__REPORTID__', reportId).replace('0', String(fileId));
 154
 155    const syncReportUnarchiveUrl  = id => syncReportUnarchiveUrlTemplate.replace('__REPORTID__', id);
 156    const syncReportArchiveUrl    = id => syncReportArchiveUrlTemplate.replace('__REPORTID__', id);
 157    const syncApplyLogUrl         = id => syncApplyLogUrlTemplate.replace('__REPORTID__', id);
 158    const syncApplyLogJobsUrl     = id => syncApplyLogJobsUrlTemplate.replace('__REPORTID__', id);
 159    const syncArchivePreviewUrl   = id => syncArchivePreviewUrlTemplate.replace('__REPORTID__', id);
 160    const syncReportLogUrl        = id => syncReportLogUrlTemplate.replace('__REPORTID__', id);
 161    const syncReportMappingCombosUrl = id => syncReportMappingCombosUrlTemplate.replace('__REPORTID__', id);
 162    const syncReportCleanupPreviewUrl = id => syncReportCleanupPreviewUrlTemplate.replace('__REPORTID__', id);
 163    const syncReportCleanupStartUrl   = id => syncReportCleanupStartUrlTemplate.replace('__REPORTID__', id);
 164    const syncReportPricePushStartUrl = id => syncReportPricePushStartUrlTemplate.replace('__REPORTID__', encodeURICompo
 165    const syncReportCleanupZeroUrl = id => syncReportCleanupZeroUrlTemplate.replace('__REPORTID__', id);
 166    const syncReportPromosUrl = id => syncReportPromosUrlTemplate.replace('__REPORTID__', id);
 167    const syncReportPriceSettingsUrl = id => syncReportPriceSettingsUrlTemplate.replace('__REPORTID__', id);
 168    const articleC1PriceUrl = article => articleC1PriceUrlTemplate.replace('__ARTICLE__', encodeURIComponent(article));
 169    const priceOptimizationUrl = (reportId, article) => priceOptimizationUrlTemplate
 170        .replace('__REPORTID__', encodeURIComponent(reportId))
 171        .replace('__ARTICLE__',  encodeURIComponent(article));
 172
 173    let syncReportConnection = null;
 174    let syncApplyConnection = null;
 175    let syncCleanupConnection = null;
 176    let syncPricePushConnection = null;
 177    let syncPricePushElapsedTimer = null;
 178    let syncPricePushStartedAt = 0;
 179    let syncPriceIndexConnection = null;
 180    let syncPriceIndexElapsedTimer = null;
 181    let syncPriceIndexStartedAt = 0;
 182    let syncCleanupStartedAt = 0;
 183    let syncCleanupElapsedTimer = null;
 184    const cleanupPreviewCache = {
 185        reportId: null,
 186        cacheKey: null,
 187        products: [],
 188        skipGroups: 0,
 189        hasMore: false,
 190        totalGroups: 0,
 191        totalWarehouses: 0,
 192        loadedAt: 0
 193    };
 194    let syncReportElapsedTimer = null;
 195    let syncReportStartedAt = 0;
 196    let lastSyncReport = null;
 197    let pendingSyncReportId = null;
 198    let currentReportId = null;
 199    // Map: "article_clientId" -> { truePrice, colorIndex }
 200    let syncPriceInfoMap  = new Map();
 201    let syncPriceUpdateMap = new Map(); // article_clientId -> {oldPrice, newPrice}
 202    // Map: "article_clientId" -> { inPromo, promoName }
 203    let syncPromoMap = new Map();
 204    // Настройки % ценообразования для текущего отчёта
 205    let syncReportPriceSettings = { noPromoMarkupPercent: 5, promoMarkupPercent: 10 };
 206    // Данные последней открытой модалки цен (для обновления таблицы и перезагрузки модалки)
 207    let _priceModalClientId = 0;
 208    let _priceModalArticle  = '';
 209    let _priceModalReportId = '';
 210    let applyLogPage = 1;
 211    const applyLogPageSize = 50;
 212    let currentReportOzonClientId = null;
 213    // Полные данные маппингов текущего отчёта (загружаются при открытии модального окна связей)
 214    let currentReportMappings = [];
 215    // Набор ключей "clientId|oneCStore|ozonWarehouseName" для строк с отключёнными связями
 216    const disabledMappingRows = new Set();
 217
 218    // Мост для гридов AG Grid (bsx-sync-runs.js, bsx-sync-report.js): форматтеры ячеек и состояние отчёта
 219    // остаются здесь, гриды используют их, чтобы разметка совпадала со старой таблицей.
 220    window.BsxSync = {
 221        escapeHtml: t => escapeHtml(t),
 222        statusBadge: (status, stage) => statusBadge(status, stage),
 223        parseStoresJson: json => parseStoresJson(json),
 224        formatRunsCountsCell: r => formatRunsCountsCell(r),
 225        formatOzonApplyCell: w => formatOzonApplyCell(w),
 226        formatCleanupCell: w => formatCleanupCell(w),
 227        formatStaleCell: w => formatStaleCell(w),
 228        getCurrentReportId: () => currentReportId,
 229        getPriceSettings: () => syncReportPriceSettings,
 230        getExcludedMappings: () => [...disabledMappingRows],
 231        getActiveJob: () => ({ reportId: _activeJobReportId, stage: _activeJobStage }),
 232        openReport: (reportId, resetPage) => openReport(reportId, resetPage).catch(err => St2Dialog.alert(err.message)),
 233        closeReport: () => { currentReportId = null; lastSyncReport = null; }
 234    };
 235
 236    function newSyncJobId() {
 237        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
 238            return window.crypto.randomUUID();
 239        }
 240        return 'sync-' + Date.now() + '-' + Math.random().toString(16).slice(2);
 241    }
 242
 243    function syncReportGetUrl(reportId) {
 244        return syncReportGetUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 245    }
 246    function syncReportOneCStoresUrl(reportId) {
 247        return syncReportOneCStoresUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 248    }
 249    function syncReportApplyStartUrl(reportId) {
 250        return syncReportApplyStartUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 251    }
 252    function syncReportExcelUrl(reportId) {
 253        return syncReportExcelUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 254    }
 255    function syncReportExcelTaskStartUrl(reportId) {
 256        return syncReportExcelTaskStartUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 257    }
 258    function syncReportExcelTaskStatusUrl(taskId) {
 259        return syncReportExcelTaskStatusUrlTemplate.replace('__TASKID__', encodeURIComponent(taskId));
 260    }
 261    function syncReportExcelTaskDownloadUrl(taskId) {
 262        return syncReportExcelTaskDownloadUrlTemplate.replace('__TASKID__', encodeURIComponent(taskId));
 263    }
 264    function syncReportProductNamesUrl(reportId) {
 265        return syncReportProductNamesUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 266    }
 267    function syncReportSendEmailUrl(reportId) {
 268        return syncReportSendEmailUrlTemplate.replace('__REPORTID__', encodeURIComponent(reportId));
 269    }
 270
 271    // ── DEBUG: pipeline event log (window._pipelineLog) ──────────────────────
 272    window._pipelineLog = [];
 273    function _pipelineConfigId(ss) {
 274        if (!ss) return 'CFG-01';
 275        const a = !!ss.autoRunApply, c = !!ss.autoRunCleanup, p = !!ss.autoRunPricePush, o = !!ss.autoRunOzonOrphanClean
 276        if (!a && !o) return 'CFG-01';
 277        if (!a && o) return 'CFG-06';
 278        if (a && !c && !p && !o) return 'CFG-02';
 279        if (a && c && !p && !o) return 'CFG-03';
 280        if (a && !c && p && !o) return 'CFG-04';
 281        if (a && c && p && !o) return 'CFG-05';
 282        if (a && c && p && o) return 'CFG-07';
 283        if (a && !c && !p && o) return 'CFG-08';
 284        if (a && c && !p && o) return 'CFG-09';
 285        if (a && !c && p && o) return 'CFG-10';
 286        return `CFG-CUSTOM(a=${a},c=${c},p=${p},o=${o})`;
 287    }
 288    function _plog(stage, event, detail) {
 289        const entry = { t: new Date().toISOString(), stage, event, detail };
 290        window._pipelineLog.push(entry);
 291        console.log(`[PIPELINE] [S${stage}] ${event}`, detail ?? '');
 292    }
 293    // Вызвать в консоли: window._pipelineLog для просмотра; window._pipelineSummary() для сводки
 294    window._pipelineSummary = () => {
 295        console.table(window._pipelineLog.map(e => ({
 296            time: e.t.slice(11,23), stage: e.stage, event: e.event,
 297            detail: typeof e.detail === 'object' ? JSON.stringify(e.detail) : String(e.detail ?? '')
 298        })));
 299    };
 300
 301    const PIPELINE_SESSION_KEY = 'bsActivePipelineReportId';
 302    let _syncSignalRJobId = null;
 303
 304    function getActivePipelineReportId() {
 305        try { return sessionStorage.getItem(PIPELINE_SESSION_KEY) || null; } catch (_) { return null; }
 306    }
 307    function setActivePipelineReportId(rid) {
 308        try {
 309            if (rid) sessionStorage.setItem(PIPELINE_SESSION_KEY, rid);
 310            else sessionStorage.removeItem(PIPELINE_SESSION_KEY);
 311        } catch (_) {}
 312    }
 313    async function findActivePipelineReport() {
 314        try {
 315            const res = await fetchWithSyncReportsFallback(`${syncReportsListUrl}?page=1&pageSize=30`, {
 316                headers: { 'Accept': 'application/json' }, credentials: 'same-origin'
 317            });
 318            const data = await readJsonResponse(res);
 319            const items = data.result?.items || data.Result?.items || [];
 320            const hit = items.find(r => (r.activeStage || r.ActiveStage || 0) > 0);
 321            return hit ? (hit.reportId || hit.ReportId) : null;
 322        } catch (_) { return null; }
 323    }
 324    async function reconnectToPipeline(reportId) {
 325        if (!reportId) return;
 326        _par._pipelineReportId = reportId;
 327        setActivePipelineReportId(reportId);
 328        _activeJobReportId = reportId;
 329        pendingSyncReportId = reportId;
 330        document.getElementById('bitrixSyncReportProgressWrap')?.classList.remove('d-none');
 331        const saved = _panelStore.load(reportId);
 332        if (saved) {
 333            _par._state = Object.assign({}, saved);
 334            const panel = document.getElementById('presetAutoRunPanel');
 335            if (panel && !saved.finished) {
 336                _par._applyStepVisibility(saved.hasApply, saved.hasCleanup, saved.hasPricePush, saved.hasOrphanCleanup);
 337                panel.classList.remove('d-none');
 338                [1, 2, 3, 4, 5].forEach(n => {
 339                    const st = (saved.steps || {})[String(n)] || 'waiting';
 340                    _par.setStep(n, st);
 341                });
 342            }
 343        }
 344        _plog(1, 'ReconnectPipeline', { reportId, sessionActive: getActivePipelineReportId() });
 345        await connectSyncSignalR(reportId);
 346        appendSyncLog('Переподключено к цепочке ' + reportId.slice(0, 8) + '…');
 347    }
 348    // ── конец DEBUG ──────────────────────────────────────────────────────────
 349
 350    // Буферы логов — сбрасываются пачкой раз в 80мс вместо DOM-update на каждое сообщение
 351    let _syncLogBuf = [], _syncLogTimer = null;
 352    let _applyLogBuf = [], _applyLogTimer = null;
 353    let _orphanLogBuf = [], _orphanLogTimer = null;
 354
 355    function _flushLog(elId, buf) {
 356        const el = document.getElementById(elId);
 357        if (!el || !buf.length) return;
 358        const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
 359        const frag = document.createDocumentFragment();
 360        for (const m of buf) {
 361            const line = document.createElement('div');
 362            line.className = 'log-line';
 363            line.textContent = '[' + formatLogTime() + '] ' + m;
 364            frag.appendChild(line);
 365        }
 366        buf.length = 0;
 367        el.appendChild(frag);
 368        if (atBottom) el.scrollTop = el.scrollHeight;
 369    }
 370
 371    // ── Персистентный лог (localStorage по reportId) ─────────────────────────
 372    let _activeJobStage = 0;
 373    let _activeJobReportId = null;
 374    const _logStore = {
 375        _key: rid => 'bsLog2_' + rid,
 376        _max: 500,
 377        push(rid, stage, msg) {
 378            if (!rid) return;
 379            try {
 380                const k = this._key(rid);
 381                const ts = '[' + formatLogTime() + ']';
 382                const arr = JSON.parse(localStorage.getItem(k) || '[]');
 383                arr.push([stage, ts, msg]);
 384                if (arr.length > this._max) arr.splice(0, arr.length - this._max);
 385                localStorage.setItem(k, JSON.stringify(arr));
 386            } catch (_) {}
 387        },
 388        clearStage(rid, stage) {
 389            if (!rid) return;
 390            try {
 391                const k = this._key(rid);
 392                const arr = JSON.parse(localStorage.getItem(k) || '[]').filter(e => e[0] !== stage);
 393                if (arr.length) localStorage.setItem(k, JSON.stringify(arr));
 394                else localStorage.removeItem(k);
 395            } catch (_) {}
 396        },
 397        restore(rid) {
 398            if (!rid) return [];
 399            try { return JSON.parse(localStorage.getItem(this._key(rid)) || '[]'); } catch (_) { return []; }
 400        }
 401    };
 402    function _restoreLogLines(elId, entries) {
 403        const el = document.getElementById(elId);
 404        if (!el || !entries.length) return;
 405        el.innerHTML = '';
 406        const frag = document.createDocumentFragment();
 407        for (const [, ts, msg] of entries) {
 408            const d = document.createElement('div');
 409            d.className = 'log-line';
 410            d.textContent = ts + ' ' + msg;
 411            frag.appendChild(d);
 412        }
 413        el.appendChild(frag);
 414        el.scrollTop = el.scrollHeight;
 415    }
 416
 417    function _hideLog(stage) {
 418        const ids = { 1: 'bitrixSyncReportProgressWrap', 2: 'bitrixSyncApplyProgressWrap', 3: 'bitrixSyncCleanupProgress
 419        document.getElementById(ids[stage])?.classList.add('d-none');
 420        _logStore.clearStage(currentReportId, stage);
 421    }
 422
 423    // ── Состояние панели авто-запуска (localStorage по reportId) ─────────────
 424    const _panelStore = {
 425        _key: rid => 'bsPanel2_' + rid,
 426        save(rid, state) {
 427            if (!rid) return;
 428            try { localStorage.setItem(this._key(rid), JSON.stringify(state)); } catch (_) {}
 429        },
 430        load(rid) {
 431            if (!rid) return null;
 432            try { return JSON.parse(localStorage.getItem(this._key(rid)) || 'null'); } catch (_) { return null; }
 433        },
 434        clear(rid) {
 435            if (!rid) return;
 436            try { localStorage.removeItem(this._key(rid)); } catch (_) {}
 437        }
 438    };
 439
 440    // Полный лог текущего/последнего запуска (не обрезается, в отличие от DOM-лога)
 441    let _parLogFull = [];
 442    let _parLogTitle = '';
 443
 444    // ── Панель авто-прогресса пресета ────────────────────────────────────────
 445    const _par = {
 446        _elapsedTimer: null,
 447        _startedAt: 0,
 448        _state: null,
 449        _pipelineReportId: null,
 450        // Цвета прогресс-бара по этапу
 451        _colors: { 1: '#0d6efd', 2: '#198754', 3: '#fd7e14', 4: '#198754', 5: '#7c4b00' },
 452        // Иконки завершённого шага
 453        _doneIcon: '<i class="fas fa-check" style="font-size:.65rem"></i>',
 454        _errorIcon: '<i class="fas fa-times" style="font-size:.65rem"></i>',
 455        _spinnerHtml: '<span class="spinner-border" style="width:10px;height:10px;border-width:1.5px"></span>',
 456
 457        _save() {
 458            const rid = this._pipelineReportId || currentReportId;
 459            if (!this._state || !rid) return;
 460            _panelStore.save(rid, this._state);
 461        },
 462
 463        setJobId(stage, jobId) {
 464            if (!this._state) return;
 465            if (stage === 2) this._state.s2JobId = jobId;
 466            else if (stage === 3) this._state.s3JobId = jobId;
 467            else if (stage === 4) this._state.s4JobId = jobId;
 468            else if (stage === 5) this._state.s5JobId = jobId;
 469            this._save();
 470        },
 471
 472        _applyStepVisibility(hasApply, hasCleanup, hasPricePush, hasOrphanCleanup) {
 473            // Сначала скрываем всё кроме шага 1
 474            [2,3,4,5].forEach(n => document.getElementById(`parStep${n}`)?.classList.add('d-none'));
 475            [1,2,3,4].forEach(n => document.getElementById(`parConn${n}`)?.classList.add('d-none'));
 476
 477            if (!hasApply) return;
 478
 479            document.getElementById('parStep2')?.classList.remove('d-none');
 480            document.getElementById('parConn1')?.classList.remove('d-none');
 481
 482            if (!hasCleanup) return;
 483
 484            document.getElementById('parStep3')?.classList.remove('d-none');
 485            document.getElementById('parConn2')?.classList.remove('d-none');
 486
 487            if (!hasPricePush) return;
 488
 489            document.getElementById('parStep4')?.classList.remove('d-none');
 490            document.getElementById('parConn3')?.classList.remove('d-none');
 491
 492            if (!hasOrphanCleanup) return;
 493
 494            document.getElementById('parStep5')?.classList.remove('d-none');
 495            document.getElementById('parConn4')?.classList.remove('d-none');
 496        },
 497
 498        show(hasApply, hasCleanup, hasPricePush, pipelineReportId, hasOrphanCleanup) {
 499            const panel = document.getElementById('presetAutoRunPanel');
 500            if (!panel) return;
 501            if (pipelineReportId) this._pipelineReportId = pipelineReportId;
 502            this._applyStepVisibility(hasApply, hasCleanup, hasPricePush, hasOrphanCleanup);
 503            // Сбрасываем все шаги в "waiting"
 504            [1,2,3,4,5].forEach(n => this.setStep(n, 'waiting'));
 505            _parLogFull = [];
 506            _parLogTitle = document.getElementById('presetAutoRunTitle')?.textContent || 'log';
 507            document.getElementById('presetAutoRunLog').innerHTML = '';
 508            document.getElementById('presetAutoRunProgressBar').style.width = '0%';
 509            document.getElementById('presetAutoRunStageText').textContent = 'Подготовка…';
 510            document.getElementById('presetAutoRunElapsed').textContent = '00:00';
 511            panel.classList.remove('d-none');
 512            this._state = {
 513                hasApply, hasCleanup, hasPricePush, hasOrphanCleanup,
 514                steps: { '1': 'waiting', '2': 'waiting', '3': 'waiting', '4': 'waiting', '5': 'waiting' },
 515                finished: false, finishedOk: false,
 516                s2JobId: null, s3JobId: null, s4JobId: null, s5JobId: null
 517            };
 518            this._save();
 519        },
 520
 521        hide() {
 522            document.getElementById('presetAutoRunPanel')?.classList.add('d-none');
 523            if (this._elapsedTimer) { clearInterval(this._elapsedTimer); this._elapsedTimer = null; }
 524        },
 525
 526        setStep(n, status) {
 527            const step = document.getElementById(`parStep${n}`);
 528            const circle = document.getElementById(`parStep${n}Circle`);
 529            if (!step || !circle) return;
 530            step.classList.remove('active', 'done', 'error');
 531            if (status === 'active') {
 532                step.classList.add('active');
 533                circle.innerHTML = this._spinnerHtml;
 534                // Обновляем цвет прогресс-бара
 535                const bar = document.getElementById('presetAutoRunProgressBar');
 536                if (bar) bar.style.background = this._colors[n];
 537                // Запускаем таймер
 538                this._startedAt = Date.now();
 539                if (this._elapsedTimer) clearInterval(this._elapsedTimer);
 540                this._elapsedTimer = setInterval(() => {
 541                    const s = Math.max(0, Math.floor((Date.now() - this._startedAt) / 1000));
 542                    const el = document.getElementById('presetAutoRunElapsed');
 543                    if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart
 544                }, 1000);
 545            } else if (status === 'done') {
 546                step.classList.add('done');
 547                circle.innerHTML = this._doneIcon;
 548                // Коннектор до следующего шага — тоже зелёный
 549                const conn = document.getElementById(`parConn${n}`);
 550                if (conn) conn.classList.add('done');
 551            } else if (status === 'error') {
 552                step.classList.add('error');
 553                circle.innerHTML = this._errorIcon;
 554            } else {
 555                circle.textContent = String(n);
 556            }
 557            if (this._state) { this._state.steps[String(n)] = status; this._save(); }
 558        },
 559
 560        setProgress(current, total, text) {
 561            const bar = document.getElementById('presetAutoRunProgressBar');
 562            const txt = document.getElementById('presetAutoRunStageText');
 563            if (!bar || !txt) return;
 564            const pct = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : (current > 0 ? 100 : 0);
 565            bar.style.width = pct + '%';
 566            if (text) txt.textContent = text;
 567        },
 568
 569        log(msg) {
 570            const formatted = '[' + formatLogTime() + '] ' + msg;
 571            // Полный буфер — без ограничения
 572            _parLogFull.push(formatted);
 573            if (_parLogTitle === '') _parLogTitle = document.getElementById('presetAutoRunTitle')?.textContent || 'log';
 574
 575            // DOM-лог в панели (последние 40 строк)
 576            const el = document.getElementById('presetAutoRunLog');
 577            if (el) {
 578                const line = document.createElement('div');
 579                line.className = 'par-log-line';
 580                line.textContent = formatted;
 581                el.appendChild(line);
 582                while (el.children.length > 40) el.removeChild(el.firstChild);
 583                el.scrollTop = el.scrollHeight;
 584            }
 585
 586            // Если модалка открыта — добавляем строку туда тоже
 587            const modal = document.getElementById('parLogModal');
 588            const modalBody = document.getElementById('parLogModalBody');
 589            if (modal && modalBody && modal.classList.contains('show')) {
 590                const mline = document.createElement('div');
 591                mline.className = 'par-log-line';
 592                mline.textContent = formatted;
 593                modalBody.appendChild(mline);
 594                modalBody.scrollTop = modalBody.scrollHeight;
 595            }
 596        },
 597
 598        finish(anyError) {
 599            if (this._state?.finished) return;
 600            if (this._elapsedTimer) { clearInterval(this._elapsedTimer); this._elapsedTimer = null; }
 601            const spinner = document.getElementById('presetAutoRunSpinner');
 602            const header  = document.getElementById('presetAutoRunHeader');
 603            if (anyError) {
 604                if (spinner) { spinner.className = ''; spinner.innerHTML = '<i class="fas fa-exclamation-triangle text-w
 605                if (header)  header.style.background = '#842029';
 606            } else {
 607                if (spinner) { spinner.className = ''; spinner.innerHTML = '<i class="fas fa-check-circle text-success" 
 608                if (header)  header.style.background = '#0f5132';
 609            }
 610            const bar = document.getElementById('presetAutoRunProgressBar');
 611            if (bar) { bar.style.width = '100%'; bar.classList.remove('progress-bar-animated', 'progress-bar-striped'); 
 612            if (this._state) { this._state.finished = true; this._state.finishedOk = !anyError; this._save(); }
 613            if (!anyError) {
 614                setActivePipelineReportId(null);
 615                this._pipelineReportId = null;
 616            }
 617        }
 618    };
 619
 620    document.getElementById('presetAutoRunClose')?.addEventListener('click', () => _par.hide());
 621
 622    // Кнопка «развернуть» — открываем модалку с полным логом
 623    document.getElementById('presetAutoRunExpand')?.addEventListener('click', () => {
 624        const title = document.getElementById('presetAutoRunTitle')?.textContent || 'Журнал запуска';
 625        document.getElementById('parLogModalTitle').textContent = title;
 626
 627        // Заполняем тело модалки текущим полным буфером
 628        const modalBody = document.getElementById('parLogModalBody');
 629        if (modalBody) {
 630            modalBody.innerHTML = '';
 631            const frag = document.createDocumentFragment();
 632            _parLogFull.forEach(line => {
 633                const div = document.createElement('div');
 634                div.className = 'par-log-line';
 635                div.textContent = line;
 636                frag.appendChild(div);
 637            });
 638            modalBody.appendChild(frag);
 639            // скролл в конец
 640            requestAnimationFrame(() => { modalBody.scrollTop = modalBody.scrollHeight; });
 641        }
 642
 643        // Спиннер в шапке модалки — показываем если цепочка ещё активна
 644        const panelHeader = document.getElementById('presetAutoRunHeader');
 645        const bg = panelHeader?.style.background || '';
 646        const isFinished = bg === '#0f5132' || bg === '#842029';
 647        const modalSpinner = document.getElementById('parLogModalSpinner');
 648        if (modalSpinner) modalSpinner.classList.toggle('d-none', isFinished);
 649
 650        bootstrap.Modal.getOrCreateInstance(document.getElementById('parLogModal')).show();
 651    });
 652
 653    // Кнопка «скачать» внутри модалки
 654    document.getElementById('parLogModalDownloadBtn')?.addEventListener('click', () => {
 655        const title = (_parLogTitle || document.getElementById('parLogModalTitle')?.textContent || 'log')
 656            .replace(/[^\w\-а-яёА-ЯЁ]/gi, '_').replace(/_+/g, '_');
 657        const ts = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
 658        const text = _parLogFull.join('\n');
 659        const blob = new Blob([text], { type: 'text/plain; charset=utf-8' });
 660        const url = URL.createObjectURL(blob);
 661        const a = document.createElement('a');
 662        a.href = url;
 663        a.download = `bitrixstock_${title}_${ts}.txt`;
 664        document.body.appendChild(a);
 665        a.click();
 666        document.body.removeChild(a);
 667        URL.revokeObjectURL(url);
 668    });
 669    // ── конец панели ────────────────────────────────────────────────────────
 670
 671    function appendSyncLog(msg) {
 672        _syncLogBuf.push(msg);
 673        if (!_syncLogTimer) _syncLogTimer = setTimeout(() => { _syncLogTimer = null; _flushLog('bitrixSyncReportLog', _s
 674        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) _par.log(msg);
 675        _logStore.push(currentReportId, 1, msg);
 676    }
 677
 678    function appendApplyLog(msg) {
 679        _applyLogBuf.push(msg);
 680        if (!_applyLogTimer) _applyLogTimer = setTimeout(() => { _applyLogTimer = null; _flushLog('bitrixSyncApplyLog', 
 681        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) _par.log(msg);
 682        _logStore.push(currentReportId, 2, msg);
 683    }
 684
 685    function appendOrphanLog(msg) {
 686        _orphanLogBuf.push(msg);
 687        if (!_orphanLogTimer) _orphanLogTimer = setTimeout(() => { _orphanLogTimer = null; _flushLog('bitrixSyncOrphanLo
 688        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) _par.log(msg);
 689        _logStore.push(currentReportId, 5, msg);
 690    }
 691
 692    // ── Маршрутизация логов пайплайна по активному этапу ─────────────────────
 693    // Оркестратор шлёт все Log в одну группу (reportId). Раскладываем их в карточку
 694    // текущего этапа, чтобы лог каждого этапа отображался отдельно (как главный лог).
 695    let _pipelineCurrentStage = 1;
 696    const _pipelineStageDom = {
 697        1: { wrap: 'bitrixSyncReportProgressWrap',    log: 'bitrixSyncReportLog',    append: appendSyncLog },
 698        2: { wrap: 'bitrixSyncApplyProgressWrap',     log: 'bitrixSyncApplyLog',     append: appendApplyLog },
 699        3: { wrap: 'bitrixSyncCleanupProgressWrap',   log: 'bitrixSyncCleanupLog',   append: appendCleanupLog },
 700        4: { wrap: 'bitrixSyncPricePushProgressWrap', log: 'bitrixSyncPricePushLog', append: appendPricePushLog },
 701        5: { wrap: 'bitrixSyncOrphanProgressWrap',    log: 'bitrixSyncOrphanLog',    append: appendOrphanLog }
 702    };
 703    function routePipelineLog(msg) {
 704        const dom = _pipelineStageDom[_pipelineCurrentStage] || _pipelineStageDom[1];
 705        (dom.append || appendSyncLog)(msg);
 706    }
 707    // Переключение активного этапа: показываем карточку этапа и очищаем её лог под новый прогон.
 708    function activatePipelineStage(stage) {
 709        if (!_pipelineStageDom[stage]) return;
 710        _pipelineCurrentStage = stage;
 711        _setStagesRowCollapsed(false);
 712        const dom = _pipelineStageDom[stage];
 713        document.getElementById(dom.wrap)?.classList.remove('d-none');
 714        const logEl = document.getElementById(dom.log);
 715        if (logEl) logEl.innerHTML = '';
 716        _logStore.clearStage(currentReportId, stage);
 717    }
 718
 719    // Возвращает целевой остаток для строки (то что должны поставить в Ozon)
 720    function getTargetQty(w) {
 721        const action = (w.action || w.Action || '').toLowerCase();
 722        if (action === 'clear' || action === 'cleanupextra' || action === 'batchvariantdisabledlink' || action === 'batc
 723        if (action === 'push' || action === 'decrease' || action === 'fbsnowarehouse') return w.oneCQty;
 724        if (action === 'batchvariant') {
 725            const mult = Number(w.batchMultiplier ?? w.BatchMultiplier ?? 1);
 726            return mult > 1 ? Math.floor(w.oneCQty / mult) : null;
 727        }
 728        return null;
 729    }
 730
 731    // Ячейка "Нужно" — целевой остаток
 732    function formatNeedoCell(w) {
 733        const t = getTargetQty(w);
 734        if (t === null) return '<span class="text-muted small">—</span>';
 735        return `<span class="fw-semibold">${t}</span>`;
 736    }
 737
 738    // Ячейка "Стало" — фактический результат наката с цветным значком изменения
 739    function formatStaleCell(w) {
 740        const qtyAfter = w.ozonQtyAfter ?? w.OzonQtyAfter;
 741        const qtyBefore = w.ozonQty ?? 0;
 742        if (qtyAfter == null) {
 743            return '<span class="text-muted small">—</span>';
 744        }
 745        const diff = qtyAfter - qtyBefore;
 746        const diffClass = diff > 0 ? 'text-success' : diff < 0 ? 'text-danger' : 'text-muted';
 747        const diffLabel = diff === 0 ? '' : `<span class="${diffClass} fw-bold" style="font-size:0.78rem">(${diff > 0 ? 
 748        return `<strong>${qtyAfter}</strong> ${diffLabel}`;
 749    }
 750
 751    // Статус применения: классифицирует причину по тексту ошибки
 752    function formatOzonApplyCell(w) {
 753        const rowAction = (w.action || w.Action || '').toString();
 754        if (rowAction === 'CleanupExtra') {
 755            const err = (w.ozonApplyError || w.OzonApplyError || w.note || w.Note || '').toString();
 756            return `<span class="badge bg-info text-dark" title="${escapeHtml(err)}">⓪₃ Этап 3</span>`;
 757        }
 758        if (rowAction === 'ClearDisabledLink') {
 759            const err = (w.ozonApplyError || w.OzonApplyError || w.note || w.Note || '').toString();
 760            const wh = escapeHtml(w.ozonWarehouseName || w.OzonWarehouseName || '');
 761            return `<span class="badge bg-secondary" title="${escapeHtml(err)}"><i class="fas fa-link-slash me-1" style=
 762        }
 763        if (rowAction === 'BatchVariant' || rowAction === 'BatchVariantDisabledLink' || rowAction === 'BatchVariantZeroS
 764            return '<span class="text-muted small" title="Обрабатывается на этапе 5, не на этапе 2">Этап 5</span>';
 765        }
 766        const s   = (w.ozonApplyStatus || w.OzonApplyStatus || '').toString();
 767        const err = (w.ozonApplyError  || w.OzonApplyError  || '').toString();
 768
 769        if (s === 'Success') {
 770            if (err) return _formatSkipReason(err);          // Success + причина = пропуск
 771            const t = w.ozonAppliedUtc || w.OzonAppliedUtc;
 772            return `<span class="badge bg-success" title="${escapeHtml(t ? formatUtc(t) : '')}">✓ Применено</span>`;
 773        }
 774        if (s === 'Failed')  return _formatFailReason(err);
 775        if (s === 'Pending') return '<span class="badge bg-secondary">ожид.</span>';
 776        if (s === 'Skipped') return err ? _formatSkipReason(err) : '<span class="badge bg-secondary">пропущено</span>';
 777        return '<span class="text-muted small">—</span>';
 778    }
 779
 780    // Пропуски (OzonApplyStatus=Success, но в Ozon ничего не отправлялось)
 781    function _formatSkipReason(err) {
 782        const e = err.toLowerCase();
 783        if (e.includes('к1 не продается') || e.includes('к1 не продаётся'))
 784            return `<span class="badge bg-warning text-dark" title="${escapeHtml(err)}">К2: К1 не продается</span>`;
 785        if (e.includes('архивный') || e.includes('архив'))
 786            return `<span class="badge bg-secondary" title="${escapeHtml(err)}">📦 Архивный</span>`;
 787        if (e.includes('уже 0'))
 788            return `<span class="badge bg-secondary" title="${escapeHtml(err)}">= Уже 0</span>`;
 789        if (e.includes('совпадает'))
 790            return `<span class="badge bg-secondary" title="${escapeHtml(err)}">= Уже совпадает</span>`;
 791        if (e.includes('не найден') || e.includes('product_id') || e.includes('склада ozon'))
 792            return `<span class="badge bg-secondary" title="${escapeHtml(err)}">⊘ Не участвовал</span>`;
 793        if (e.startsWith('к2') && e.includes('приоритет'))
 794            return `<span class="badge bg-primary text-white" title="${escapeHtml(err)}">⓪ К2 обнулено (К1)</span>`;
 795        if (e.startsWith('к1') && e.includes('приоритет'))
 796            return `<span class="badge bg-light text-dark border" title="${escapeHtml(err)}">К1 → К2 обнулён</span>`;
 797        if (e.includes('связь отключена') || e.includes('обнулено'))
 798            return `<span class="badge bg-secondary" title="${escapeHtml(err)}"><i class="fas fa-link-slash me-1" style=
 799        if (e.includes('к2 пропущен')) {
 800            const reason = err.replace(/^К2 пропущен: /i, '');
 801            return `<span class="badge bg-secondary" title="${escapeHtml(err)}">К2: ${escapeHtml(reason.substring(0, 28)
 802        }
 803        const short = err.length > 35 ? err.substring(0, 35) + '…' : err;
 804        return `<span class="badge bg-secondary" title="${escapeHtml(err)}">• ${escapeHtml(short)}</span>`;
 805    }
 806
 807    function formatCleanupCell(w) {
 808        const rowAction = (w.action || w.Action || '').toString();
 809        if (rowAction === 'BatchVariantDisabledLink') {
 810            const mult = w.batchMultiplier ?? w.BatchMultiplier ?? 1;
 811            const base = w.batchBaseArticle ?? w.BatchBaseArticle ?? '';
 812            return `<span class="badge bg-secondary" title="Снятая связь (этап 2) — склад обнулён на этапе 5&#10;Партия 
 813        }
 814        if (rowAction === 'BatchVariantNotCreated') {
 815            const mult = w.batchMultiplier ?? w.BatchMultiplier ?? 1;
 816            const note = w.note ?? w.Note ?? '';
 817            return `<span class="badge bg-danger" title="${escapeHtml(note)}"><i class="fas fa-triangle-exclamation me-1
 818        }
 819        if (rowAction === 'BatchVariantZeroStock') {
 820            const mult = w.batchMultiplier ?? w.BatchMultiplier ?? 1;
 821            const qty1c = w.oneCQty ?? w.OneCQty ?? 0;
 822            const base = w.batchBaseArticle ?? w.BatchBaseArticle ?? '';
 823            const tooltip = `Недостаточно для упаковки&#10;1С: ${qty1c} шт. < ${mult} шт./уп. → 0 уп.&#10;Базовый: ${esc
 824            return `<span class="badge bsx-tone-warning" title="${tooltip}"><i class="fas fa-box-open me-1" style="font-
 825        }
 826        if (rowAction === 'BatchVariantNotInFbs') {
 827            const mult = w.batchMultiplier ?? w.BatchMultiplier ?? 1;
 828            const qty1c = w.oneCQty ?? w.OneCQty ?? 0;
 829            const note = w.note ?? w.Note ?? '';
 830            const tooltip = `Товар не добавлен в FBS управление остатками&#10;1С: ${qty1c} шт. | ×${mult}&#10;${escapeHt
 831            return `<span class="badge bsx-tone-warning" title="${tooltip}"><i class="fas fa-store-slash me-1" style="fo
 832        }
 833        if (rowAction === 'BatchVariant') {
 834            const mult = w.batchMultiplier ?? w.BatchMultiplier ?? 1;
 835            const base = w.batchBaseArticle ?? w.BatchBaseArticle ?? '';
 836            const pushedPrice = w.batchPushedPrice ?? w.BatchPushedPrice ?? null;
 837            const targetQty = mult > 1 ? Math.floor(w.oneCQty / mult) : null;
 838            const stockLine = targetQty != null
 839                ? `1С: ${w.oneCQty} шт. → ${targetQty} пак. (было ${w.ozonQty})`
 840                : `было ${w.ozonQty} пак.`;
 841            const priceLine = pushedPrice != null
 842                ? ` · ${Math.round(pushedPrice).toLocaleString('ru-RU')} ₽`
 843                : '';
 844            const tooltip = `Партия ×${mult} | Базовый: ${escapeHtml(base)} | ${stockLine}${priceLine}`;
 845            const priceHtml = pushedPrice != null
 846                ? `<span class="d-block bsx-tone-text-accent" style="font-size:0.7rem">${Math.round(pushedPrice).toLocal
 847                : '';
 848            return `<span class="badge bsx-tone-accent" title="${escapeHtml(tooltip)}"><i class="fas fa-boxes me-1" styl
 849        }
 850        const s = (w.ozonCleanupStatus || w.OzonCleanupStatus || '').toString();
 851        const err = (w.ozonCleanupError || w.OzonCleanupError || '').toString();
 852        if (!s) return '<span class="text-muted small">—</span>';
 853        if (s === 'Success') {
 854            if (err.includes('Сохранён'))
 855                return `<span class="badge bg-light text-dark border" title="${escapeHtml(err)}">✓ Сохранён</span>`;
 856            if (err.includes('⓪₃') || err.includes('Этап 3'))
 857                return `<span class="badge bsx-tone-info sync-row-cleanup-extra" title="${escapeHtml(err)}">⓪₃ Обнулён</
 858            if (err.includes('Обнулён'))
 859                return `<span class="badge bg-info text-dark" title="${escapeHtml(err)}">⓪ Обнулён</span>`;
 860            return `<span class="badge bg-success" title="${escapeHtml(err)}">✓</span>`;
 861        }
 862        if (s === 'Failed')
 863            return `<span class="badge bg-danger" title="${escapeHtml(err)}">✗</span>`;
 864        if (s === 'Skipped')
 865            return `<span class="badge bg-secondary" title="${escapeHtml(err)}">пропуск</span>`;
 866        return `<span class="text-muted small">${escapeHtml(s)}</span>`;
 867    }
 868
 869    // Ошибки применения (OzonApplyStatus=Failed)
 870    function _formatFailReason(err) {
 871        const e = (err || '').toLowerCase();
 872        if (e.includes('не продается') || e.includes('не продаётся'))
 873            return `<span class="badge bg-warning text-dark" title="${escapeHtml(err)}">⊘ Не продается</span>`;
 874        if (e.includes('склад') && (e.includes('архиве') || e.includes('не создан'))) {
 875            const short = err.length > 55 ? err.substring(0, 55) + '…' : err;
 876            return `<span class="badge bg-secondary mb-1" title="${escapeHtml(err)}">⚠ Склад</span>`
 877                 + `<small class="text-muted d-block lh-sm" style="font-size:0.7rem;word-break:break-word;max-width:200p
 878        }
 879        const short = err.length > 60 ? err.substring(0, 60) + '…' : err;
 880        return `<span class="badge bg-danger mb-1" title="${escapeHtml(err)}">✗ Ошибка</span>`
 881             + `<small class="text-danger lh-sm d-block" style="font-size:0.72rem;word-break:break-word;max-width:200px"
 882    }
 883
 884    // Показывает спиннер загрузки внутри таблицы результатов
 885    // Индикатор загрузки строк — оверлей AG Grid (BsxReportGrid).
 886    function showTableLoading() { }
 887
 888    // Перестраивает Set ключей скрытых строк по состоянию галок в localStorage
 889    function refreshDisabledMappingRows(reportId) {
 890        disabledMappingRows.clear();
 891        if (!reportId) return;
 892        const state   = JSON.parse(localStorage.getItem(`bitrixSyncDisabledMappings_${reportId}`) || '{}');
 893        const details = JSON.parse(localStorage.getItem(`bitrixSyncMappingDetails_${reportId}`) || '{}');
 894        for (const [id, enabled] of Object.entries(state)) {
 895            if (!enabled && details[id]) {
 896                const d = details[id];
 897                disabledMappingRows.add(`${d.clientId}|${d.bitrixWarehouse}|${d.ozonWarehouse}`);
 898            }
 899        }
 900    }
 901
 902    // Проверяет, должна ли строка таблицы быть скрыта из-за отключённой связи
 903    function isMappingDisabled(w) {
 904        if (disabledMappingRows.size === 0) return false;
 905        const key = `${w.ozonClientId || 0}|${w.oneCStore || ''}|${w.ozonWarehouseName || ''}`;
 906        return disabledMappingRows.has(key);
 907    }
 908
 909    let _syncProgressPending = null, _syncProgressRaf = null;
 910    function setSyncProgress(current, total, text) {
 911        _syncProgressPending = { current, total, text };
 912        if (!_syncProgressRaf) _syncProgressRaf = requestAnimationFrame(() => {
 913            _syncProgressRaf = null;
 914            const p = _syncProgressPending; _syncProgressPending = null;
 915            const bar = document.getElementById('bitrixSyncReportProgressBar');
 916            const txt = document.getElementById('bitrixSyncReportProgressText');
 917            if (!bar || !txt) return;
 918            const pct = p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : 0;
 919            bar.style.width = pct + '%';
 920            bar.textContent = pct + '%';
 921            txt.textContent = p.text || (p.total > 0 ? `Обработано ${p.current} из ${p.total}` : 'Выполнение…');
 922            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 923                _par.setProgress(p.current, p.total, p.text);
 924        });
 925    }
 926
 927    function startSyncElapsed() {
 928        syncReportStartedAt = Date.now();
 929        if (syncReportElapsedTimer) clearInterval(syncReportElapsedTimer);
 930        syncReportElapsedTimer = setInterval(() => {
 931            const s = Math.max(0, Math.floor((Date.now() - syncReportStartedAt) / 1000));
 932            const el = document.getElementById('bitrixSyncReportElapsed');
 933            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 934        }, 1000);
 935    }
 936
 937    function escapeHtml(t) {
 938        const d = document.createElement('div');
 939        d.textContent = t ?? '';
 940        return d.innerHTML;
 941    }
 942
 943    function formatLogTime() {
 944        return window.BitrixStockMsk ? BitrixStockMsk.formatNowTime() : new Date().toLocaleTimeString('ru-RU');
 945    }
 946
 947    function formatUtc(d) {
 948        return window.BitrixStockMsk ? BitrixStockMsk.format(d) : (d ? String(d) : '—');
 949    }
 950
 951    function parseStoresJson(json) {
 952        if (!json) return '—';
 953        try {
 954            const arr = JSON.parse(json);
 955            if (Array.isArray(arr)) return arr.join(', ');
 956        } catch (_) { }
 957        return json;
 958    }
 959
 960    const _stageLabel = { 1: 'Эт.1 Отчёт', 2: 'Эт.2 Накат', 3: 'Эт.3 Очистка', 4: 'Эт.4 Цены', 5: 'Эт.5 Сироты' };
 961    const _stageCls   = { 1: 'bg-primary', 2: 'bg-success', 3: 'bg-warning text-dark', 4: 'bg-success', 5: 'text-white' 
 962    const _stageStyle = { 5: 'background:#7c4b00' };
 963    const _spinner    = '<span class="spinner-border spinner-border-sm me-1" style="width:.65rem;height:.65rem;border-wi
 964
 965    function statusBadge(status, activeStage) {
 966        const s = (status || '').toString();
 967        if (activeStage > 0) {
 968            const cls = _stageCls[activeStage] || 'bg-primary';
 969            const style = _stageStyle[activeStage] ? ` style="${_stageStyle[activeStage]}"` : '';
 970            return `<span class="badge ${cls} stage-run-badge"${style}>${_spinner}${_stageLabel[activeStage] || ('Эт.' +
 971        }
 972        if (s === 'Completed') return '<span class="badge bg-success">Готово</span>';
 973        if (s === 'Running')   return `<span class="badge bg-primary stage-run-badge">${_spinner}Выполняется</span>`;
 974        if (s === 'Failed')    return '<span class="badge bg-danger">Ошибка</span>';
 975        if (s === 'Pending')   return '<span class="badge bg-secondary">Ожидает</span>';
 976        return `<span class="badge bg-secondary">${escapeHtml(s || '—')}</span>`;
 977    }
 978
 979    async function readJsonResponse(res) {
 980        const text = await res.text();
 981        if (!text || !text.trim()) {
 982            throw new Error(res.ok ? 'Пустой ответ сервера' : `HTTP ${res.status}: пустой ответ`);
 983        }
 984        try {
 985            return JSON.parse(text);
 986        } catch (_) {
 987            throw new Error(`HTTP ${res.status}: ответ не JSON`);
 988        }
 989    }
 990
 991    function extractReportId(payload, fallback) {
 992        if (payload == null) return fallback;
 993        let p = payload;
 994        if (typeof payload === 'string') {
 995            const t = payload.trim();
 996            if (!t) return fallback;
 997            try { p = JSON.parse(t); } catch (_) { return fallback; }
 998        }
 999        if (p && typeof p === 'object') {
 1000            return p.reportId || p.ReportId || fallback;
 1001        }
 1002        return fallback;
 1003    }
 1004
 1005
 1006
 1007    // Фильтр по клиентам — справочный фильтр колонки «Клиент» в гриде отчёта.
 1008    function renderClientFilter(report) { }
 1009
 1010
 1011    // Параметры старых эндпоинтов (Excel, почта) — из фильтров грида отчёта.
 1012    function buildFilterParams() {
 1013        return window.BsxReportGrid ? window.BsxReportGrid.legacyParams() : new URLSearchParams();
 1014    }
 1015
 1016    // Склады 1С — справочный фильтр колонки «1С» (счётчики с сервера).
 1017    async function loadResultOneCStores(reportId) { }
 1018
 1019    async function fetchReport(reportId, attempt) {
 1020        // Только шапка и сводка отчёта: строки грузит грид (grid-rows) блоками с серверными фильтрами.
 1021        const params = new URLSearchParams();
 1022        params.set('page', '1');
 1023        params.set('pageSize', '20');
 1024        const url = `${syncReportGetUrl(reportId)}?${params.toString()}`;
 1025        const res = await fetch(url, {
 1026            headers: { 'Accept': 'application/json' },
 1027            credentials: 'same-origin'
 1028        });
 1029        const data = await readJsonResponse(res);
 1030        if (res.status === 404 && (attempt || 0) < 12) {
 1031            await new Promise(r => setTimeout(r, 500));
 1032            return fetchReport(reportId, (attempt || 0) + 1);
 1033        }
 1034        if (!res.ok || !data.success) {
 1035            throw new Error(data.error || `Не удалось загрузить отчёт (HTTP ${res.status})`);
 1036        }
 1037        return data.report;
 1038    }
 1039
 1040    function renderSummary(report) {
 1041        const s = report.summary;
 1042        const el = document.getElementById('bitrixSyncReportSummary');
 1043        if (!el || !s) return;
 1044        const status = report.status ? statusBadge(report.status) : '';
 1045        el.innerHTML =
 1046            `${status} <strong>${escapeHtml(report.ozonClientName)}</strong> · ${formatUtc(report.generatedAtUtc)}` +
 1047            (report.completedAtUtc ? ` — завершён ${formatUtc(report.completedAtUtc)}` : '') +
 1048            ` · <span class="text-muted">dry-run</span><br/>` +
 1049            `Товаров: ${s.productsInReport} из ${s.productsScanned} · строк: ${s.warehouseLinesTotal} · в выборке: <span
 1050            `<span class="sync-action-badge sync-row-push">↑ ${s.pushCount}</span> ` +
 1051            `<span class="sync-action-badge sync-row-decrease">↓ ${s.decreaseCount}</span> ` +
 1052            `<span class="sync-action-badge sync-row-clear">⊘ ${s.clearCount}</span> ` +
 1053            `<span class="sync-action-badge sync-row-match">✓ ${s.matchCount}</span> ` +
 1054            `<span class="sync-action-badge sync-row-skip">— ${s.skipCount}</span> ` +
 1055            `<span class="sync-action-badge sync-row-not-found">? ${s.notFoundCount ?? 0}</span><br/>` +
 1056            `<span class="text-success fw-semibold">Ozon ✓ ${report.ozonAppliedSuccessCount ?? 0}</span>` +
 1057            (report.ozonApplyFailedCount ? ` · <span class="text-danger">ошибок ${report.ozonApplyFailedCount}</span>` :
 1058            (report.ozonApplyPendingCount ? ` · <span class="text-muted">ожидает ${report.ozonApplyPendingCount}</span>`
 1059            (report.ozonApplyLastAtUtc ? ` · <span class="text-muted">последняя синхр. ${formatUtc(report.ozonApplyLastA
 1060            ((report.ozonCleanupSuccessCount || 0) > 0 || (report.ozonCleanupFailedCount || 0) > 0
 1061                ? ` · <span class="sync-action-badge sync-row-cleanup-extra">⓪₃ этап 3: ✓${report.ozonCleanupSuccessCoun
 1062                  ((report.ozonCleanupFailedCount || 0) > 0 ? ` ✗${report.ozonCleanupFailedCount}` : '') + '</span>'
 1063                : '') +
 1064            (report.archivedCount > 0 ? ` · <span class="badge-archived ms-1">В архиве: ${report.archivedCount}${report.
 1065        if (report.selectedStoresJson) {
 1066            el.innerHTML += `<br/><span class="text-muted">Склады при формировании: ${escapeHtml(parseStoresJson(report.
 1067        }
 1068        if (report.warning) {
 1069            el.innerHTML += `<br/><span class="text-warning">${escapeHtml(report.warning)}</span>`;
 1070        }
 1071        [['Push', s.pushCount], ['Decrease', s.decreaseCount], ['Clear', s.clearCount],
 1072         ['Match', s.matchCount], ['Skip', s.skipCount], ['NotFound', s.notFoundCount ?? 0],
 1073         ['CleanupExtra', report.ozonCleanupSuccessCount ?? 0]
 1074        ].forEach(([act, cnt]) => {
 1075            const span = document.getElementById('syncActionCount_' + act);
 1076            if (span) span.textContent = cnt > 0 ? `(${cnt})` : '';
 1077        });
 1078    }
 1079
 1080
 1081    // Таблица результатов — AG Grid (BsxReportGrid): перечитывает блоки с сохранением прокрутки и фильтров.
 1082    function renderTable(report) {
 1083        if (window.BsxReportGrid) window.BsxReportGrid.refresh();
 1084    }
 1085
 1086
 1087
 1088    async function loadSyncReportPriceInfo(reportId) {
 1089        const r = await fetch(`/Studio2/BitrixStock/GetSyncReportPriceInfo?reportId=${encodeURIComponent(reportId)}`);
 1090        const arr = r.ok ? await r.json() : [];
 1091        syncPriceInfoMap  = new Map();
 1092        syncPriceUpdateMap = new Map();
 1093        for (const e of arr)
 1094            syncPriceInfoMap.set(`${e.article}_${e.ozonClientId}`, e);
 1095        const ciCounts = { SUPER: 0, GREEN: 0, YELLOW: 0, RED: 0, NONE: 0 };
 1096        for (const e of arr) {
 1097            const ci = e.colorIndex || '';
 1098            const bucket = (!ci || ci === 'WITHOUT_INDEX') ? 'NONE' : ci;
 1099            if (bucket in ciCounts) ciCounts[bucket]++;
 1100        }
 1101        [['SUPER','Super'],['GREEN','Green'],['YELLOW','Yellow'],['RED','Red'],['NONE','None']].forEach(([val, suffix]) 
 1102            const el = document.getElementById('syncPriceIdxCount_' + suffix);
 1103            if (el) el.textContent = ciCounts[val] > 0 ? `(${ciCounts[val]})` : '';
 1104        });
 1105    }
 1106
 1107    async function openReport(reportId, resetPage) {
 1108        currentReportId = reportId;
 1109        // Полноэкранный вид отчёта со своим URL (?tab=sync&report=<id>) вместо модалки.
 1110        window.BsxSyncView?.showReport(reportId);
 1111        onOrphanReportLoaded(reportId);
 1112        syncPriceInfoMap  = new Map();
 1113        syncPromoMap     = new Map();
 1114        syncPriceUpdateMap = new Map();
 1115        syncReportPriceSettings = { noPromoMarkupPercent: 5, promoMarkupPercent: 10 };
 1116
 1117        // Загружаем promo-статус и настройки % параллельно с ценами
 1118        fetch(syncReportPromosUrl(reportId))
 1119            .then(r => r.ok ? r.json() : [])
 1120            .then(arr => {
 1121                syncPromoMap = new Map();
 1122                for (const e of arr)
 1123                    syncPromoMap.set(`${e.article}_${e.ozonClientId}`, e);
 1124            })
 1125            .catch(() => {});
 1126        fetch(syncReportPriceSettingsUrl(reportId))
 1127            .then(r => r.ok ? r.json() : null)
 1128            .then(s => { if (s) syncReportPriceSettings = { noPromoMarkupPercent: s.noPromoMarkupPercent ?? 5, promoMark
 1129            .catch(() => {});
 1130
 1131        loadSyncReportPriceInfo(reportId).catch(() => {});
 1132        refreshDisabledMappingRows(reportId);
 1133
 1134        const idEl = document.getElementById('bitrixSyncCurrentReportId');
 1135        if (idEl) idEl.textContent = reportId;
 1136
 1137        window.BsxRuns?.redrawRows();
 1138
 1139        lastSyncReport = await fetchReport(reportId);
 1140        currentReportOzonClientId = lastSyncReport.ozonClientId;
 1141        renderSummary(lastSyncReport);
 1142        window.BsxReportGrid?.open(reportId, lastSyncReport);
 1143        document.getElementById('bitrixSyncReportExportBtn')?.classList.remove('d-none');
 1144        document.getElementById('bitrixSyncReportSendEmailBtn')?.classList.remove('d-none');
 1145        document.getElementById('bitrixSyncReportDownloadLogBtn')?.classList.remove('d-none');
 1146        document.getElementById('bitrixSyncApplyLogBtn')?.style.setProperty('display', '');
 1147        const applyBtn = document.getElementById('bitrixSyncApplyStartBtn');
 1148        if (applyBtn) applyBtn.disabled = false;
 1149        const cleanupPreviewBtn = document.getElementById('bitrixSyncCleanupPreviewBtn');
 1150        const cleanupStartBtn = document.getElementById('bitrixSyncCleanupStartBtn');
 1151        if (cleanupPreviewBtn) cleanupPreviewBtn.disabled = false;
 1152        if (cleanupStartBtn) cleanupStartBtn.disabled = (lastSyncReport?.ozonAppliedSuccessCount || 0) <= 0;
 1153        document.getElementById('bitrixSyncPricePushStartBtn')?.removeAttribute('disabled');
 1154        invalidateCleanupPreviewCache();
 1155        resetCleanupPreviewUi();
 1156
 1157        // Восстановить сохранённые логи для этого отчёта
 1158        const _restoredLogs = _logStore.restore(reportId);
 1159        if (_restoredLogs.length) {
 1160            const s1 = _restoredLogs.filter(e => e[0] === 1);
 1161            const s2 = _restoredLogs.filter(e => e[0] === 2);
 1162            const s3 = _restoredLogs.filter(e => e[0] === 3);
 1163            const s4 = _restoredLogs.filter(e => e[0] === 4);
 1164            if (s1.length) {
 1165                document.getElementById('bitrixSyncReportProgressWrap')?.classList.remove('d-none');
 1166                _restoreLogLines('bitrixSyncReportLog', s1);
 1167            }
 1168            if (s2.length) {
 1169                document.getElementById('bitrixSyncApplyProgressWrap')?.classList.remove('d-none');
 1170                _restoreLogLines('bitrixSyncApplyLog', s2);
 1171            }
 1172            if (s3.length) {
 1173                document.getElementById('bitrixSyncCleanupProgressWrap')?.classList.remove('d-none');
 1174                _restoreLogLines('bitrixSyncCleanupLog', s3);
 1175            }
 1176            if (s4.length) {
 1177                document.getElementById('bitrixSyncPricePushProgressWrap')?.classList.remove('d-none');
 1178                _restoreLogLines('bitrixSyncPricePushLog', s4);
 1179            }
 1180            const s5 = _restoredLogs.filter(e => e[0] === 5);
 1181            if (s5.length) {
 1182                document.getElementById('bitrixSyncOrphanProgressWrap')?.classList.remove('d-none');
 1183                _restoreLogLines('bitrixSyncOrphanLog', s5);
 1184            }
 1185        }
 1186
 1187        // Восстановить панель авто-запуска если была активна
 1188        const _panelState = _panelStore.load(reportId);
 1189        if (_panelState) {
 1190            const panel = document.getElementById('presetAutoRunPanel');
 1191            if (panel) {
 1192                _par._pipelineReportId = reportId;
 1193                _par._state = Object.assign({}, _panelState);
 1194                _par._applyStepVisibility(_panelState.hasApply, _panelState.hasCleanup, _panelState.hasPricePush, _panel
 1195                document.getElementById('presetAutoRunLog').innerHTML = '';
 1196                document.getElementById('presetAutoRunProgressBar').style.width = '0%';
 1197                document.getElementById('presetAutoRunElapsed').textContent = '00:00';
 1198                panel.classList.remove('d-none');
 1199                // Применяем состояние шагов визуально
 1200                [1, 2, 3, 4, 5].forEach(n => {
 1201                    const st = (_panelState.steps || {})[String(n)] || 'waiting';
 1202                    _par.setStep(n, st);
 1203                });
 1204                if (_panelState.finished) {
 1205                    _par.finish(!_panelState.finishedOk);
 1206                } else {
 1207                    const activeRid = getActivePipelineReportId();
 1208                    if (activeRid && activeRid !== reportId) {
 1209                        appendSyncLog(`Активная цепочка: ${activeRid.slice(0, 8)}… (этот отчёт не в прогоне)`);
 1210                        _panelStore.clear(reportId);
 1211                    } else {
 1212                        setTimeout(() => {
 1213                            appendSyncLog('Переподключение к цепочке этапов…');
 1214                            reconnectToPipeline(reportId).catch(() => {});
 1215                        }, 200);
 1216                    }
 1217                }
 1218            }
 1219        }
 1220    }
 1221
 1222    async function loadMappingsForClients(clientIds) {
 1223        const result = [];
 1224        for (const clientId of (clientIds || [])) {
 1225            const url = warehouseMappingsUrlTemplate.replace('__CLIENTID__', clientId);
 1226            const res = await fetch(url, { credentials: 'same-origin' });
 1227            const data = await readJsonResponse(res);
 1228            if (!res.ok || !data.success || !Array.isArray(data.mappings)) continue;
 1229            const clientName = (lastSyncReport?.multiClientNames && lastSyncReport.multiClientNames[clientId]) || `ID ${
 1230            for (const m of data.mappings) {
 1231                result.push({ ...m, _clientId: clientId, _clientName: clientName });
 1232            }
 1233        }
 1234        return result;
 1235    }
 1236
 1237    async function openDisabledMappingsModal() {
 1238        const reportClientIds = (lastSyncReport?.multiClientIds || []).filter(id => Number(id) > 0);
 1239        const fallbackClientId = currentReportOzonClientId ? [currentReportOzonClientId] : [];
 1240        const clientIds = reportClientIds.length ? reportClientIds : fallbackClientId;
 1241        if (!clientIds.length) {
 1242            St2Dialog.alert('Клиенты отчета не определены');
 1243            return;
 1244        }
 1245
 1246        const modal = new bootstrap.Modal(document.getElementById('bitrixSyncDisabledMappingsModal'));
 1247        await loadModalWarehouseMappings(clientIds);
 1248        modal.show();
 1249    }
 1250
 1251    async function loadModalWarehouseMappings(clientIds) {
 1252        try {
 1253            const [allMappings, combosResp] = await Promise.all([
 1254                loadMappingsForClients(clientIds),
 1255                currentReportId
 1256                    ? fetch(syncReportMappingCombosUrl(currentReportId), { credentials: 'same-origin' }).then(r => r.jso
 1257                    : Promise.resolve({ success: false, combos: [] })
 1258            ]);
 1259
 1260            if (!allMappings.length) {
 1261                document.getElementById('bitrixSyncModalMappingsContainer').innerHTML =
 1262                    '<span class="small text-danger">Связи для выбранных клиентов не найдены</span>';
 1263                return;
 1264            }
 1265
 1266            // Строим набор комбо из ВСЕГО отчёта (все страницы, не только текущая)
 1267            const mappingsInReport = new Set();
 1268            if (combosResp.success && Array.isArray(combosResp.combos)) {
 1269                combosResp.combos.forEach(c => mappingsInReport.add(`${c.clientId}|${c.oneCStore}|${c.ozonWarehouse}`));
 1270            }
 1271
 1272            const reportMappings = mappingsInReport.size > 0
 1273                ? allMappings.filter(m => mappingsInReport.has(`${m._clientId}|${m.bitrixWarehouse}|${m.ozonWarehouse}`)
 1274                : allMappings; // fallback: показать все если endpoint недоступен
 1275
 1276            // Сохраняем глобально и в localStorage чтобы фильтровать строки таблицы без API-запроса
 1277            currentReportMappings = reportMappings;
 1278            if (currentReportId) {
 1279                const mappingDetails = {};
 1280                reportMappings.forEach(m => {
 1281                    mappingDetails[m.id] = { ozonWarehouse: m.ozonWarehouse, bitrixWarehouse: m.bitrixWarehouse, clientI
 1282                });
 1283                localStorage.setItem(`bitrixSyncMappingDetails_${currentReportId}`, JSON.stringify(mappingDetails));
 1284                refreshDisabledMappingRows(currentReportId);
 1285            }
 1286
 1287            const container = document.getElementById('bitrixSyncModalMappingsContainer');
 1288            if (reportMappings.length === 0) {
 1289                container.innerHTML = '<span class="small text-muted">Нет связей в отчете</span>';
 1290                return;
 1291            }
 1292
 1293            // Загрузить сохраненное состояние галок из localStorage
 1294            let savedState = JSON.parse(localStorage.getItem(`bitrixSyncDisabledMappings_${currentReportId}`) || '{}');
 1295
 1296            // Если состояния нет в localStorage, проверяем есть ли временные отключенные связи из пресета
 1297            if (Object.keys(savedState).length === 0) {
 1298                const tempDisabled = JSON.parse(localStorage.getItem('bitrixSyncTempDisabledMappings') || '[]');
 1299                if (tempDisabled.length > 0) {
 1300                    // Преобразуем массив отключенных ID в объект состояния
 1301                    tempDisabled.forEach(id => { savedState[id] = false; });
 1302                    localStorage.removeItem('bitrixSyncTempDisabledMappings');
 1303                }
 1304            }
 1305
 1306            let html = '<div class="row g-2">';
 1307            reportMappings.forEach(m => {
 1308                // Логика: галка = активна, без галки = отключена
 1309                // Если savedState[m.id] === false, значит связь отключена (нет галки)
 1310                const isDisabled = savedState[m.id] === false;
 1311                const checked = !isDisabled; // галка стоит если связь не отключена
 1312                html += `
 1313                <div class="col-md-6">
 1314                    <div class="border rounded p-2 bg-white">
 1315                        <div class="form-check">
 1316                            <input class="form-check-input bitrix-sync-modal-disabled-mapping-cb" type="checkbox"
 1317                                   value="${m.id}" id="bitrixSyncModalDisabledMapping_${m.id}" ${checked ? 'checked' : '
 1318                            <label class="form-check-label" for="bitrixSyncModalDisabledMapping_${m.id}">
 1319                                <div class="small fw-500">${escapeHtml(m.bitrixWarehouse)}</div>
 1320                                <div class="small text-muted"><i class="fas fa-arrow-right me-1"></i>${escapeHtml(m.ozon
 1321                                <div class="small text-primary"><i class="fas fa-user me-1"></i>${escapeHtml(m._clientNa
 1322                            </label>
 1323                        </div>
 1324                    </div>
 1325                </div>`;
 1326            });
 1327            html += '</div>';
 1328            container.innerHTML = html;
 1329            document.getElementById('bitrixSyncModalMappingsError').classList.add('d-none');
 1330            updateModalZeroWarning();
 1331        } catch (e) {
 1332            console.error('Ошибка загрузки связей:', e);
 1333            document.getElementById('bitrixSyncModalMappingsContainer').innerHTML = '';
 1334            const errorDiv = document.getElementById('bitrixSyncModalMappingsError');
 1335            errorDiv.textContent = 'Ошибка: ' + e.message;
 1336            errorDiv.classList.remove('d-none');
 1337        }
 1338    }
 1339
 1340    function applyDisabledMappingsAndRecalculate() {
 1341        if (!currentReportId) return;
 1342
 1343        const state = {};
 1344        document.querySelectorAll('.bitrix-sync-modal-disabled-mapping-cb').forEach(cb => {
 1345            state[cb.value] = cb.checked;
 1346        });
 1347        localStorage.setItem(`bitrixSyncDisabledMappings_${currentReportId}`, JSON.stringify(state));
 1348        refreshDisabledMappingRows(currentReportId);
 1349
 1350        const modal = bootstrap.Modal.getInstance(document.getElementById('bitrixSyncDisabledMappingsModal'));
 1351        if (modal) modal.hide();
 1352
 1353        const disabledCount = [...document.querySelectorAll('.bitrix-sync-modal-disabled-mapping-cb')].filter(cb => !cb.
 1354        appendSyncLog(`✓ Настройки связей применены${disabledCount > 0 ? ` — отключено связей: ${disabledCount}` : ''}.`
 1355        if (lastSyncReport) renderTable(lastSyncReport);
 1356    }
 1357
 1358    function updateModalZeroWarning() {
 1359        const unchecked = [...document.querySelectorAll('.bitrix-sync-modal-disabled-mapping-cb')].filter(cb => !cb.chec
 1360        const warningDiv = document.getElementById('bitrixSyncModalZeroWarning');
 1361        const list = document.getElementById('bitrixSyncModalZeroWarningList');
 1362        if (!warningDiv || !list) return;
 1363
 1364        if (unchecked.length === 0) {
 1365            warningDiv.classList.add('d-none');
 1366            return;
 1367        }
 1368
 1369        const detailsById = Object.fromEntries((currentReportMappings || []).map(m => [String(m.id), m]));
 1370        list.innerHTML = unchecked.map(cb => {
 1371            const m = detailsById[cb.value];
 1372            return m
 1373                ? `<li><i class="fas fa-link-slash me-1 text-warning"></i><strong>${escapeHtml(m.bitrixWarehouse)}</stro
 1374                : `<li><i class="fas fa-link-slash me-1 text-warning"></i>Связь #${escapeHtml(cb.value)}</li>`;
 1375        }).join('');
 1376        warningDiv.classList.remove('d-none');
 1377    }
 1378
 1379    document.getElementById('bitrixSyncModalMappingsContainer')?.addEventListener('change', function (e) {
 1380        if (e.target.classList.contains('bitrix-sync-modal-disabled-mapping-cb')) {
 1381            updateModalZeroWarning();
 1382        }
 1383    });
 1384
 1385    function formatRunsCountsCell(r) {
 1386        const stockPart = `↑${r.pushCount} ↓${r.decreaseCount} ⊘${r.clearCount}`;
 1387        const priceOk = r.pricePushCount ?? 0;
 1388        const priceUnch = r.pricePushUnchangedCount ?? 0;
 1389        const priceErr = r.pricePushErrorCount ?? 0;
 1390        const orphansFound = r.orphansFound ?? 0;
 1391        const orphansZeroed = r.orphansZeroed ?? 0;
 1392
 1393        const priceParts = [];
 1394        if (priceOk) priceParts.push(`<span class="text-success">₽${priceOk}</span>`);
 1395        if (priceUnch) priceParts.push(`<span class="text-muted">≈${priceUnch}</span>`);
 1396        if (priceErr) priceParts.push(`<span class="text-danger">✗${priceErr}</span>`);
 1397        const pricePart = priceParts.length
 1398            ? priceParts.join(' ')
 1399            : '<span class="text-muted">₽—</span>';
 1400
 1401        const orphanIcon = '<i class="fas fa-ghost bsx-orphan-icon"></i>';
 1402        const orphanPart = orphansFound || orphansZeroed
 1403            ? `${orphanIcon}<span class="${orphansZeroed ? 'text-success' : 'text-danger'}">${orphansFound}</span>` +
 1404              (orphansZeroed ? `<span class="text-muted">/</span><span class="text-success">${orphansZeroed}</span>` : '
 1405            : `${orphanIcon}<span class="text-muted">—</span>`;
 1406
 1407        return `<span class="bsx-runs-counts-cell">${stockPart} · ${pricePart} · ${orphanPart}</span>`;
 1408    }
 1409
 1410    async function startSyncReportExcelExport(reportId, btn, filterParams, options) {
 1411        if (!reportId || !window.St2ChannelTask) return;
 1412        options = options || {};
 1413        const params = filterParams || new URLSearchParams();
 1414        const baseUrl = syncReportExcelTaskStartUrl(reportId);
 1415        const query = params.toString();
 1416        const startUrl = query
 1417            ? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${query}`
 1418            : baseUrl;
 1419        await St2ChannelTask.run({
 1420            startUrl,
 1421            startMethod: 'POST',
 1422            statusUrl: taskId => St2ChannelTask.channelStatusUrl(taskId),
 1423            downloadUrl: taskId => St2ChannelTask.channelDownloadUrl(taskId),
 1424            modalId: 'bitrixSyncExcelExportModal',
 1425            triggerBtn: btn,
 1426            loadingHtml: options.loadingHtml || '<i class="fas fa-circle-notch fa-spin"></i>',
 1427            doneHtml: options.doneHtml,
 1428            resultKind: 'File'
 1429        });
 1430        if (btn && options.doneClassSwap) {
 1431            btn.classList.replace(options.doneClassSwap[0], options.doneClassSwap[1]);
 1432            setTimeout(() => btn.classList.replace(options.doneClassSwap[1], options.doneClassSwap[0]), 1200);
 1433        }
 1434    }
 1435
 1436    // История запусков — AG Grid (BsxRuns): весь список, сортировка/фильтры/поиск на клиенте, автообновление активных.
 1437    async function loadRunsList(options) {
 1438        if (window.BsxRuns) await window.BsxRuns.reload(options);
 1439    }
 1440
 1441    async function connectSyncSignalR(jobId) {
 1442        if (typeof signalR === 'undefined') {
 1443            throw new Error('SignalR не загружен');
 1444        }
 1445        if (syncReportConnection && _syncSignalRJobId === jobId &&
 1446            syncReportConnection.state === signalR.HubConnectionState.Connected) {
 1447            _plog(1, 'JoinJobGroupSkipped', { jobId, reason: 'already connected' });
 1448            return;
 1449        }
 1450        if (syncReportConnection) {
 1451            try { await syncReportConnection.stop(); } catch (_) { }
 1452        }
 1453        _syncSignalRJobId = jobId;
 1454        syncReportConnection = new signalR.HubConnectionBuilder()
 1455            .withUrl('/jobProgressHub')
 1456            .withAutomaticReconnect()
 1457            .build();
 1458
 1459        // Все этапы 1→5 шлют Log/Progress в ОДНУ группу (rootJobId == reportId). Клиент подписан один раз.
 1460        // Лог раскладывается в карточку активного этапа (см. routePipelineLog / activatePipelineStage).
 1461        syncReportConnection.on('Log', msg => routePipelineLog(msg));
 1462        syncReportConnection.on('Progress', (c, t, text) => {
 1463            setSyncProgress(c, t, text);
 1464            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 1465                _par.setProgress(c, t, text);
 1466        });
 1467        // Стейдж-уровневый Complete больше НЕ управляет цепочкой — он только в журнале.
 1468        // Цепочку ведёт серверный оркестратор; UI рисуется по событиям Pipeline* ниже.
 1469        syncReportConnection.on('Complete', (success, payload) => {
 1470            if (success === false || success === 'false')
 1471                appendSyncLog('Этап завершился с ошибкой: ' + (payload || ''));
 1472        });
 1473        syncReportConnection.on('Error', msg => appendSyncLog('ОШИБКА: ' + msg));
 1474
 1475        const _stageLabels = {
 1476            1: 'Этап 1 — генерация отчёта',
 1477            2: 'Этап 2 — накат на Ozon',
 1478            3: 'Этап 3 — очистка лишних складов',
 1479            4: 'Этап 4 — накат цен',
 1480            5: 'Этап 5 — очистка сирот Ozon'
 1481        };
 1482        let tabPipelineTotal = 5;
 1483
 1484        syncReportConnection.on('PipelineStarted', ({ reportId, stages }) => {
 1485            const activeRid = getActivePipelineReportId();
 1486            if (activeRid && reportId && activeRid !== reportId) {
 1487                _plog(1, 'PipelineStartedIgnored', { reportId, activeRid, stages });
 1488                return;
 1489            }
 1490            pendingSyncReportId = reportId;
 1491            _pipelineCurrentStage = 1; // новый прогон — лог идёт в карточку этапа 1
 1492            tabPipelineTotal = Array.isArray(stages) && stages.length ? stages.length : 5;
 1493            if (window.TabStatus) {
 1494                window.TabStatus.clear('bitrix-pipeline-error');
 1495                window.TabStatus.progress({ key: 'bitrix-pipeline', label: 'Синхронизация', current: 1, total: tabPipeli
 1496            }
 1497            _plog(1, 'PipelineStarted', { reportId, stages, configId: window._lastPipelineConfigId });
 1498        });
 1499
 1500        syncReportConnection.on('PipelineStage', async ({ reportId, stage, state, stats }) => {
 1501            const activeRid = getActivePipelineReportId();
 1502            if (activeRid && reportId && activeRid !== reportId) {
 1503                _plog(stage, 'PipelineStageIgnored', { reportId, activeRid, state });
 1504                return;
 1505            }
 1506            _plog(stage, 'PipelineStage', { reportId, state, stats, configId: window._lastPipelineConfigId });
 1507            // Этап стартовал — переключаем маршрутизацию логов на его карточку и показываем её.
 1508            if (state === 'running') {
 1509                activatePipelineStage(stage);
 1510                if (window.TabStatus) {
 1511                    window.TabStatus.progress({ key: 'bitrix-pipeline', label: 'Синхронизация', current: stage, total: t
 1512                }
 1513                if (stage === 5) {
 1514                    const src = stats?.catalogSource || 'OzonProductsDb';
 1515                    const srcLabel = src === 'WarehouseStockReport'
 1516                        ? 'Ozon xlsx warehouse/stock (legacy)'
 1517                        : 'OzonApi.ozon_products + остатки Ozon API';
 1518                    appendOrphanLog(`Этап 5: источник каталога — ${srcLabel} (${src})`);
 1519                    _plog(5, 'Stage5CatalogSource', { catalogSource: src, label: srcLabel });
 1520                }
 1521            }
 1522            const panelVisible = !document.getElementById('presetAutoRunPanel')?.classList.contains('d-none');
 1523            if (panelVisible) {
 1524                if (state === 'running' && _stageLabels[stage])
 1525                    document.getElementById('presetAutoRunStageText').textContent = _stageLabels[stage];
 1526                if (stage >= 1 && stage <= 5) {
 1527                    if (state === 'running') _par.setStep(stage, 'active');
 1528                    else if (state === 'done' || state === 'skipped') _par.setStep(stage, 'done');
 1529                    else if (state === 'failed') _par.setStep(stage, 'error');
 1530                }
 1531            }
 1532            // После завершения этапа 1 — открываем сформированный отчёт.
 1533            if (stage === 1 && state === 'done' && reportId) {
 1534                try {
 1535                    appendSyncLog('Загрузка отчёта…');
 1536                    await loadRunsList();
 1537                    document.getElementById('bitrixSyncStartStoresSection')?.classList.remove('d-none');
 1538                    await openReport(reportId, true);
 1539                    appendSyncLog('Отчёт готов');
 1540                } catch (e) {
 1541                    appendSyncLog('Ошибка загрузки отчёта: ' + e.message);
 1542                }
 1543            }
 1544        });
 1545
 1546        syncReportConnection.on('PipelineFinished', async ({ reportId, success, error }) => {
 1547            _plog(0, 'PipelineFinished', { reportId, success, error, configId: window._lastPipelineConfigId });
 1548            if (window.TabStatus) {
 1549                window.TabStatus.clear('bitrix-pipeline');
 1550                if (success) window.TabStatus.complete({ key: 'bitrix-pipeline-finished', label: 'Синхронизация завершен
 1551                else window.TabStatus.error({ key: 'bitrix-pipeline-error', label: 'Ошибка синхронизации' });
 1552            }
 1553            if (reportId) setActivePipelineReportId(null);
 1554            _par._pipelineReportId = null;
 1555            const bar = document.getElementById('bitrixSyncReportProgressBar');
 1556            if (bar) bar.classList.remove('progress-bar-animated');
 1557            if (syncReportElapsedTimer) clearInterval(syncReportElapsedTimer);
 1558            const btn = document.getElementById('bitrixSyncReportStartBtn');
 1559            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-file-alt me-1"></i>Сформировать отчёт'; }
 1560            _activeJobStage = 0;
 1561            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 1562                _par.finish(!success);
 1563            if (!success && error) appendSyncLog('Цепочка прервана: ' + error);
 1564            try { await loadRunsList(); } catch (_) { }
 1565            setTimeout(() => _hideLog(1), 1500);
 1566        });
 1567
 1568        await syncReportConnection.start();
 1569        await syncReportConnection.invoke('JoinJobGroup', jobId);
 1570        _plog(1, 'JoinJobGroup', { jobId, configId: window._lastPipelineConfigId });
 1571        appendSyncLog('Подключено к журналу процесса');
 1572    }
 1573
 1574    // ---- Мультиклиентный выбор (форма нового отчёта) ----
 1575    const SYNC_CLIENT_ORDER_KEY = 'bitrixSyncClientOrder';
 1576    const syncClientOrder = []; // [{id, name}, ...] в порядке приоритета
 1577
 1578    function saveClientOrder() {
 1579        try { localStorage.setItem(SYNC_CLIENT_ORDER_KEY, JSON.stringify(syncClientOrder)); } catch (_) {}
 1580    }
 1581
 1582    function loadClientOrder() {
 1583        try {
 1584            const saved = JSON.parse(localStorage.getItem(SYNC_CLIENT_ORDER_KEY) || '[]');
 1585            if (Array.isArray(saved)) {
 1586                // Проверяем что клиенты ещё существуют (есть в select)
 1587                const sel = document.getElementById('bitrixSyncClientAddSelect');
 1588                const validIds = sel ? new Set(Array.from(sel.options).map(o => parseInt(o.value)).filter(id => id > 0))
 1589                syncClientOrder.length = 0;
 1590                for (const c of saved) {
 1591                    if (c.id && c.name && (!validIds || validIds.has(c.id))) {
 1592                        syncClientOrder.push({ id: c.id, name: c.name });
 1593                    }
 1594                }
 1595            }
 1596        } catch (_) {}
 1597    }
 1598
 1599    function renderClientList() {
 1600        const container = document.getElementById('bitrixSyncClientList');
 1601        const emptyMsg = document.getElementById('bitrixSyncClientListEmpty');
 1602        if (!container) return;
 1603        const chips = syncClientOrder.map((c, i) =>
 1604            `<span class="badge bg-primary d-flex align-items-center gap-1" style="font-size:0.78rem">
 1605                <span class="text-white-50 small">${i + 1}.</span> ${escapeHtml(c.name)}
 1606                <button type="button" class="btn-close btn-close-white btn-sm p-0 ms-1" style="font-size:0.6rem" data-re
 1607            </span>`
 1608        );
 1609        container.innerHTML = chips.join('');
 1610        if (emptyMsg) emptyMsg.style.display = syncClientOrder.length ? 'none' : '';
 1611    }
 1612
 1613    // Восстанавливаем список при загрузке страницы
 1614    loadClientOrder();
 1615    renderClientList();
 1616
 1617    document.getElementById('bitrixSyncClientAddSelect')?.addEventListener('change', function () {
 1618        const id = parseInt(this.value);
 1619        const name = this.options[this.selectedIndex]?.getAttribute('data-name') || '';
 1620        this.value = '';
 1621        if (!id || syncClientOrder.find(c => c.id === id)) return;
 1622        syncClientOrder.push({ id, name });
 1623        renderClientList();
 1624        saveClientOrder();
 1625    });
 1626
 1627    document.getElementById('bitrixSyncClientList')?.addEventListener('click', e => {
 1628        const btn = e.target.closest('[data-remove-client]');
 1629        if (!btn) return;
 1630        const id = parseInt(btn.getAttribute('data-remove-client'));
 1631        const idx = syncClientOrder.findIndex(c => c.id === id);
 1632        if (idx >= 0) syncClientOrder.splice(idx, 1);
 1633        renderClientList();
 1634        saveClientOrder();
 1635    });
 1636
 1637    function getSelectedClientIds() {
 1638        return syncClientOrder.map(c => c.id);
 1639    }
 1640    // ---- конец мультиклиент (форма) ----
 1641
 1642    // ---- Мультиклиентный выбор (модаль пресета) ----
 1643    const presetClientOrder = []; // [{id, name}, ...] в порядке приоритета
 1644
 1645    function renderPresetClientList() {
 1646        const container = document.getElementById('syncPresetClientList');
 1647        const emptyMsg  = document.getElementById('syncPresetClientListEmpty');
 1648        if (!container) return;
 1649        container.innerHTML = presetClientOrder.map((c, i) =>
 1650            `<span class="badge bg-primary d-flex align-items-center gap-1" style="font-size:0.78rem">
 1651                <span class="text-white-50 small">${i + 1}.</span> ${escapeHtml(c.name)}
 1652                <button type="button" class="btn-close btn-close-white btn-sm p-0 ms-1" style="font-size:0.6rem" data-re
 1653            </span>`
 1654        ).join('');
 1655        if (emptyMsg) emptyMsg.style.display = presetClientOrder.length ? 'none' : '';
 1656
 1657        // Синхронизируем скрытый select (для обратной совместимости)
 1658        const hiddenSel = document.getElementById('syncPresetClient');
 1659        if (hiddenSel && presetClientOrder.length)
 1660            hiddenSel.value = String(presetClientOrder[0].id);
 1661
 1662        // Перезагружаем список связей для этапов 1/2/5
 1663        if (presetClientOrder.length) {
 1664            void loadPresetDisabledMappings(getPresetClientIds().map(String));
 1665            void loadPresetApplyMappings(getPresetClientIds().map(String));
 1666            void loadPresetOrphanMappings(getPresetClientIds().map(String));
 1667        }
 1668    }
 1669
 1670    function getPresetClientIds() { return presetClientOrder.map(c => c.id); }
 1671    function getPresetClients()   { return [...presetClientOrder]; }
 1672
 1673    document.getElementById('syncPresetClientAddSelect')?.addEventListener('change', function () {
 1674        const id   = parseInt(this.value);
 1675        const name = this.options[this.selectedIndex]?.getAttribute('data-name') || '';
 1676        this.value = '';
 1677        if (!id || presetClientOrder.find(c => c.id === id)) return;
 1678        presetClientOrder.push({ id, name });
 1679        renderPresetClientList();
 1680        if (presetClientOrder.length >= 1) {
 1681            loadPresetDisabledMappings(getPresetClientIds().map(String));
 1682            loadPresetApplyMappings(getPresetClientIds().map(String));
 1683            loadPresetOrphanMappings(getPresetClientIds().map(String));
 1684        }
 1685    });
 1686
 1687    document.getElementById('syncPresetClientList')?.addEventListener('click', e => {
 1688        const btn = e.target.closest('[data-remove-preset-client]');
 1689        if (!btn) return;
 1690        const id  = parseInt(btn.getAttribute('data-remove-preset-client'));
 1691        const idx = presetClientOrder.findIndex(c => c.id === id);
 1692        if (idx >= 0) presetClientOrder.splice(idx, 1);
 1693        renderPresetClientList();
 1694    });
 1695    // ---- конец мультиклиент (пресет) ----
 1696
 1697    async function startSyncReport() {
 1698        const clientIds = getSelectedClientIds();
 1699        if (!clientIds.length) {
 1700            St2Dialog.alert('Добавьте хотя бы одного клиента Ozon');
 1701            return;
 1702        }
 1703
 1704        const stores = Array.from(document.querySelectorAll('.bitrix-sync-start-store-cb:checked'))
 1705            .map(cb => cb.value);
 1706        if (!stores.length) {
 1707            St2Dialog.alert('Выберите хотя бы один склад 1С');
 1708            return;
 1709        }
 1710
 1711        // Получить отключенные связи из пресета (если был применен)
 1712        const disabledMappings = [];
 1713        const tempDisabled = JSON.parse(localStorage.getItem('bitrixSyncTempDisabledMappings') || '[]');
 1714        if (Array.isArray(tempDisabled)) {
 1715            disabledMappings.push(...tempDisabled.map(id => Number(id)));
 1716        }
 1717        localStorage.removeItem('bitrixSyncTempDisabledMappings');
 1718
 1719        const _ss = window._presetStageSettingsForStart || null;
 1720        const serverActive = await findActivePipelineReport();
 1721        if (serverActive) {
 1722            _plog(1, 'StartBlocked', { reason: 'active pipeline on server', serverActive, requestedPreset: !!_ss });
 1723            const ok = await St2Dialog.confirm(
 1724                `Уже выполняется цепочка (${serverActive.slice(0, 8)}…). Переподключиться к ней?`,
 1725                { confirmText: 'Переподключиться', cancelText: 'Отмена' }
 1726            );
 1727            if (ok) await reconnectToPipeline(serverActive);
 1728            window._presetStageSettingsForStart = null;
 1729            return;
 1730        }
 1731
 1732        const jobId = newSyncJobId();
 1733        window._presetStageSettingsForStart = null;
 1734        setActivePipelineReportId(jobId);
 1735        _par._pipelineReportId = jobId;
 1736
 1737        if (_ss && (_ss.autoRunApply || _ss.autoRunOzonOrphanCleanup)) {
 1738            const presetName = document.getElementById('presetAutoRunTitle');
 1739            const _hasApply = !!_ss.autoRunApply;
 1740            const _hasCleanup = !!(_ss.autoRunApply && _ss.autoRunCleanup);
 1741            const _hasPricePush = !!(_ss.autoRunApply && _ss.autoRunPricePush);
 1742            const _hasOrphanCleanup = !!_ss.autoRunOzonOrphanCleanup;
 1743            if (presetName && !presetName.textContent) presetName.textContent = 'Пресет';
 1744            _par.show(_hasApply, _hasCleanup, _hasPricePush, jobId, _hasOrphanCleanup);
 1745            _par.setStep(1, 'active');
 1746            document.getElementById('presetAutoRunStageText').textContent = 'Этап 1 — генерация отчёта';
 1747        }
 1748
 1749        const body = {
 1750            jobId,
 1751            ozonClientIds: clientIds,
 1752            article: document.getElementById('bitrixSyncStartArticle')?.value?.trim() || null,
 1753            selectedStoreTitles: stores,
 1754            onlyRequiresAction: false,
 1755            disabledWarehouseMappingIds: disabledMappings.length > 0 ? disabledMappings : null,
 1756            stageSettings: _ss
 1757        };
 1758
 1759        window._pipelineLog = [];
 1760        const _configId = _pipelineConfigId(_ss);
 1761        _plog(1, 'StartSyncReport', { jobId, configId: _configId, stageSettings: _ss ? {
 1762            autoRunApply: _ss.autoRunApply, autoRunCleanup: _ss.autoRunCleanup,
 1763            autoRunPricePush: _ss.autoRunPricePush, autoRunOzonOrphanCleanup: _ss.autoRunOzonOrphanCleanup,
 1764            stage5CatalogSource: _ss.stage5CatalogSource || 'OzonProductsDb'
 1765        } : null });
 1766        document.getElementById('bitrixSyncReportProgressWrap')?.classList.remove('d-none');
 1767        document.getElementById('bitrixSyncStartStoresSection')?.classList.add('d-none');
 1768        _syncLogBuf.length = 0; clearTimeout(_syncLogTimer); _syncLogTimer = null;
 1769        document.getElementById('bitrixSyncReportLog').innerHTML = '';
 1770        _logStore.clearStage(jobId, 1);
 1771        _activeJobStage = 1;
 1772        _activeJobReportId = jobId;
 1773
 1774        const btn = document.getElementById('bitrixSyncReportStartBtn');
 1775        if (btn) {
 1776            btn.disabled = true;
 1777            btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Формирование…';
 1778        }
 1779
 1780        pendingSyncReportId = null;
 1781        startSyncElapsed();
 1782        setSyncProgress(0, 1, 'Запуск…');
 1783
 1784        try {
 1785            await connectSyncSignalR(jobId);
 1786            const res = await fetch(syncStartUrl, {
 1787                method: 'POST',
 1788                headers: { 'Content-Type': 'application/json' },
 1789                body: JSON.stringify(body)
 1790            });
 1791            const data = await readJsonResponse(res);
 1792            if (!res.ok || !data.success) {
 1793                if (res.status === 409 && data.activeReportId) {
 1794                    setActivePipelineReportId(null);
 1795                    const ok = await St2Dialog.confirm(
 1796                        `Уже выполняется цепочка (${String(data.activeReportId).slice(0, 8)}…). Переподключиться?`,
 1797                        { confirmText: 'Переподключиться', cancelText: 'Отмена' }
 1798                    );
 1799                    if (ok) await reconnectToPipeline(data.activeReportId);
 1800                    return;
 1801                }
 1802                throw new Error(data.error || 'Не удалось запустить отчёт');
 1803            }
 1804            pendingSyncReportId = data.reportId;
 1805            _activeJobReportId = data.reportId;
 1806            setActivePipelineReportId(data.reportId);
 1807            _par._pipelineReportId = data.reportId;
 1808            _par._save();
 1809            appendSyncLog('Задание в очереди (ID: ' + data.reportId + ')');
 1810            await loadRunsList();
 1811        } catch (e) {
 1812            setActivePipelineReportId(null);
 1813            _par._pipelineReportId = null;
 1814            appendSyncLog('Ошибка: ' + e.message);
 1815            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-file-alt me-1"></i>Сформировать отчёт'; }
 1816        }
 1817    }
 1818
 1819    // ---- event listeners ----
 1820
 1821    document.getElementById('bitrixSyncStartStoresAll')?.addEventListener('click', () => {
 1822        document.querySelectorAll('.bitrix-sync-start-store-cb').forEach(cb => { cb.checked = true; });
 1823    });
 1824    document.getElementById('bitrixSyncStartStoresNone')?.addEventListener('click', () => {
 1825        document.querySelectorAll('.bitrix-sync-start-store-cb').forEach(cb => { cb.checked = false; });
 1826    });
 1827    // Фильтры результатов, пагинация отчёта и истории — в гридах AG Grid (bsx-sync-report.js, bsx-sync-runs.js).
 1828
 1829    document.getElementById('bitrixSyncRunsTable')?.addEventListener('click', async e => {
 1830        const exportBtn = e.target.closest('.bitrix-sync-export-excel');
 1831        if (exportBtn) {
 1832            const reportId = exportBtn.getAttribute('data-report-id');
 1833            if (!reportId) return;
 1834            try { await startSyncReportExcelExport(reportId, exportBtn, new URLSearchParams()); } catch (err) { St2Dialo
 1835            return;
 1836        }
 1837        const btn = e.target.closest('.bitrix-sync-open-report');
 1838        if (!btn) return;
 1839        const reportId = btn.getAttribute('data-report-id');
 1840        if (!reportId) return;
 1841        try { await openReport(reportId, true); } catch (err) { St2Dialog.alert(err.message); }
 1842    });
 1843
 1844    // ── Журнал наката ────────────────────────────────────────────────────────
 1845    const ACTION_LABELS = {
 1846        'APPLY':           { text: 'Накатить',                  cls: 'bg-success' },
 1847        'UNARCHIVE_APPLY': { text: 'Разархив + накатить',        cls: 'bg-warning text-dark' },
 1848        'CLEAR':           { text: 'Обнулить (Clear)',           cls: 'bg-danger' },
 1849        'SKIP_NOTFOUND':   { text: 'Пропустить (не найден)',     cls: 'bg-secondary' },
 1850        'SKIP_ARCHIVED':   { text: 'Не трогать (архивный)',      cls: 'bg-secondary' },
 1851        'SKIP_ZERO':       { text: 'Пропустить (уже 0)',         cls: 'bg-light text-muted border' },
 1852        'SKIP_MATCH':      { text: 'Пропустить (совпадает)',     cls: 'bg-light text-muted border' }
 1853    };
 1854
 1855    function actionBadge(code) {
 1856        const def = ACTION_LABELS[code] || { text: code || '—', cls: 'bg-light text-muted border' };
 1857        return `<span class="badge ${def.cls}" style="font-size:0.72rem">${def.text}</span>`;
 1858    }
 1859
 1860    // Журнал наката — грид AG Grid: К1/К2 до → после и действия по каждому артикулу/складу.
 1861    const applyLogQty = c => `${c?.qtyBefore != null ? c.qtyBefore : '—'} → <strong>${c?.qtyAfter != null ? c.qtyAfter :
 1862    const applyLogClient = c => escapeHtml(c?.clientName || '—') + (c?.wasArchived ? ' <span class="badge bg-secondary">
 1863    const applyLogTable = window.BsxGrid?.localTable('applyLogContainer', {
 1864        key: 'applyLog',
 1865        emptyText: 'Нет записей',
 1866        gridOptions: { rowHeight: 34 },
 1867        columnDefs: [
 1868            { field: 'article', headerName: 'Артикул', width: 170, pinned: 'left', cellClass: 'fw-semibold' },
 1869            { field: 'oneCStore', headerName: 'Склад 1С', width: 100, filter: window.BsxGrid?.SetFilter, floatingFilterC
 1870            { field: 'oneCQty', headerName: 'Q 1С', width: 85, type: 'numericColumn', filter: 'agNumberColumnFilter' },
 1871            { colId: 'c1', headerName: 'К1', width: 150, valueGetter: p => p.data?.client1?.clientName || '', cellRender
 1872            { colId: 'c1qty', headerName: 'К1 до → после', width: 125, sortable: false, filter: false, cellRenderer: p =
 1873            { colId: 'c1act', headerName: 'Действие К1', width: 170, valueGetter: p => p.data?.client1?.actionCode || ''
 1874              filter: window.BsxGrid?.SetFilter, floatingFilterComponent: window.BsxGrid?.SetFloatingFilter, cellRendere
 1875            { colId: 'c2', headerName: 'К2', width: 150, valueGetter: p => p.data?.client2?.clientName || '', cellRender
 1876            { colId: 'c2qty', headerName: 'К2 до → после', width: 125, sortable: false, filter: false, cellRenderer: p =
 1877            { colId: 'c2act', headerName: 'Действие К2', width: 170, valueGetter: p => p.data?.client2?.actionCode || ''
 1878              filter: window.BsxGrid?.SetFilter, floatingFilterComponent: window.BsxGrid?.SetFloatingFilter, cellRendere
 1879            { field: 'summary', headerName: 'Итог', flex: 1, minWidth: 200, tooltipField: 'summary' }
 1880        ]
 1881    });
 1882
 1883    async function loadApplyLog() {
 1884        if (!currentReportId) return;
 1885        const info      = document.getElementById('applyLogPageInfo');
 1886        if (!applyLogTable) return;
 1887        applyLogTable.loading(true);
 1888        try {
 1889            const jobId  = document.getElementById('applyLogJobSelect')?.value || '';
 1890            const art    = document.getElementById('applyLogArticleFilter')?.value?.trim() || '';
 1891            const params = new URLSearchParams({ page: String(applyLogPage), pageSize: String(applyLogPageSize) });
 1892            if (jobId) params.set('jobId', jobId);
 1893            if (art)   params.set('article', art);
 1894            const res  = await fetch(`${syncApplyLogUrl(currentReportId)}?${params}`, { credentials: 'same-origin' });
 1895            const data = await readJsonResponse(res);
 1896            if (!res.ok || !data.success) throw new Error(data.error || 'Ошибка');
 1897            const log  = data.log;
 1898            applyLogTable.setRows(log.items || []);
 1899            renderApplyLogAnalysis(log);
 1900            if (info) info.textContent = `Стр. ${log.page} из ${log.totalPages} · ${log.totalCount} записей`;
 1901            document.getElementById('applyLogPrevPage').disabled = log.page <= 1;
 1902            document.getElementById('applyLogNextPage').disabled = log.page >= log.totalPages;
 1903        } catch (e) {
 1904            applyLogTable.message(e.message);
 1905        }
 1906    }
 1907
 1908    // Сводка журнала по артикулу/складу — схема действий по клиентам.
 1909    const applyLogAnalysisTable = window.BsxGrid?.localTable('applyLogAnalysisTable', {
 1910        key: 'applyLogAnalysis',
 1911        emptyText: 'Нет данных',
 1912        gridOptions: { getRowHeight: p => Math.max(34, 8 + 20 * ((p.data?.clients || []).length || 1)) },
 1913        columnDefs: [
 1914            { field: 'article', headerName: 'Артикул', width: 170, cellClass: 'fw-semibold' },
 1915            { field: 'oneCStore', headerName: 'Склад 1С', width: 100, filter: window.BsxGrid?.SetFilter, floatingFilterC
 1916            { field: 'oneCQty', headerName: 'Q_1С', width: 85, type: 'numericColumn', filter: 'agNumberColumnFilter' },
 1917            { colId: 'clients', headerName: 'Схема по клиентам', flex: 1, minWidth: 300, cellClass: 'bsx-cell-multiline'
 1918              valueGetter: p => (p.data?.clients || []).map(c => `${c.clientName} ${c.actionCode}`).join(' '),
 1919              cellRenderer: p => '<div class="bsx-cell-stack">' + (p.data?.clients || []).map(c =>
 1920                  `<span><span class="fw-semibold">${escapeHtml(c.clientName || '—')}</span> <span class="text-muted">[$
 1921                  `${c.qtyBefore != null ? c.qtyBefore : '—'} → ${c.qtyAfter != null ? c.qtyAfter : '—'}${c.actionLabel 
 1922        ]
 1923    });
 1924
 1925    function renderApplyLogAnalysis(log) {
 1926        const container = document.getElementById('applyLogAnalysisContainer');
 1927        if (!container) return;
 1928        if (!log) {
 1929            container.innerHTML = '';
 1930            return;
 1931        }
 1932
 1933        const totals = {};
 1934        const grouped = {};
 1935        const items = log.items || [];
 1936        for (const e of items) {
 1937            const c = e.client1 || {};
 1938            const clientName = c.clientName || '—';
 1939            totals[clientName] = (totals[clientName] || 0) + 1;
 1940            const key = `${e.article || ''}||${e.oneCStore || ''}`;
 1941            if (!grouped[key]) {
 1942                grouped[key] = {
 1943                    article: e.article || '',
 1944                    oneCStore: e.oneCStore || '',
 1945                    oneCQty: e.oneCQty || 0,
 1946                    clients: []
 1947                };
 1948            }
 1949            grouped[key].clients.push({
 1950                clientName,
 1951                actionCode: c.actionCode || '',
 1952                qtyBefore: c.qtyBefore,
 1953                qtyAfter: c.qtyAfter,
 1954                actionLabel: c.actionLabel || ''
 1955            });
 1956        }
 1957        const totalsHtml = Object.entries(totals)
 1958            .map(([name, count]) => `<span class="badge bg-light text-dark border me-1">${escapeHtml(name)}: ${count}</s
 1959            .join('');
 1960
 1961
 1962        container.innerHTML = `
 1963            <div class="alert alert-info py-2 mb-2 small">
 1964                <div class="fw-semibold mb-1">Что видно по логам (сводка):</div>
 1965                <div>Прогон: <strong>${log.totalCount || items.length || 0}</strong> строк журнала, групп: <strong>${Obj
 1966                <div class="mt-1">${totalsHtml}</div>
 1967            </div>`;
 1968        const tableEl = document.getElementById('applyLogAnalysisTable');
 1969        if (tableEl) tableEl.hidden = false;
 1970        applyLogAnalysisTable?.setRows(Object.values(grouped));
 1971    }
 1972
 1973    // Превью архивных товаров перед накатом — грид с фильтром по типу архива.
 1974    const archivePreviewTable = window.BsxGrid?.localTable('syncArchivePreviewGrid', {
 1975        key: 'archivePreview',
 1976        emptyText: 'Нет данных',
 1977        gridOptions: { getRowClass: p => p.data?.isAutoArchived ? 'bsx-row-auto-archived' : '' },
 1978        columnDefs: [
 1979            { field: 'article', headerName: 'Артикул', width: 160, pinned: 'left', cellClass: 'fw-semibold' },
 1980            { field: 'clientName', headerName: 'Клиент', width: 140, filter: window.BsxGrid?.SetFilter, floatingFilterCo
 1981            { field: 'oneCStore', headerName: 'Склад 1С', width: 100, filter: window.BsxGrid?.SetFilter, floatingFilterC
 1982            { field: 'oneCQty', headerName: 'Ост. 1С', width: 90, type: 'numericColumn', filter: 'agNumberColumnFilter' 
 1983            { field: 'ozonWarehouse', headerName: 'Склад Ozon', flex: 1, minWidth: 140, filter: window.BsxGrid?.SetFilte
 1984            { field: 'ozonQty', headerName: 'Ост. Ozon', width: 95, type: 'numericColumn', filter: 'agNumberColumnFilter
 1985            { colId: 'archiveType', headerName: 'Тип архива', width: 120, valueGetter: p => p.data?.isAutoArchived ? 'ав
 1986              filter: window.BsxGrid?.SetFilter, floatingFilterComponent: window.BsxGrid?.SetFloatingFilter,
 1987              cellRenderer: p => p.value === 'авто' ? '<span class="badge bg-danger">авто</span>' : '<span class="badge 
 1988        ]
 1989    });
 1990
 1991    function renderArchivePreviewData(data) {
 1992        const previewTable = document.getElementById('syncArchivePreviewTable');
 1993        const previewSpinner = document.getElementById('syncArchivePreviewSpinner');
 1994        const filterWrap = document.getElementById('syncArchiveFilterWrap');
 1995        const moreNote = document.getElementById('syncArchivePreviewMore');
 1996
 1997        if (!data.success) {
 1998            if (previewSpinner) previewSpinner.classList.add('d-none');
 1999            return;
 2000        }
 2001
 2002        const items = data.items || [];
 2003        if (previewTable) previewTable.classList.remove('d-none');
 2004        archivePreviewTable?.setRows(items);
 2005        if (moreNote && items.length >= 500) moreNote.classList.remove('d-none');
 2006
 2007        if (previewSpinner) previewSpinner.classList.add('d-none');
 2008        if (filterWrap && data.totalAuto > 0 && data.totalManual > 0) {
 2009            filterWrap.classList.remove('d-none');
 2010        }
 2011    }
 2012
 2013    function loadAndShowArchivePreview() {
 2014        const previewWrap = document.getElementById('syncArchivePreviewWrap');
 2015        const previewSpinner = document.getElementById('syncArchivePreviewSpinner');
 2016        const previewTable = document.getElementById('syncArchivePreviewTable');
 2017        const filterWrap = document.getElementById('syncArchiveFilterWrap');
 2018        const moreNote = document.getElementById('syncArchivePreviewMore');
 2019        if (!previewWrap) return;
 2020
 2021        previewWrap.classList.remove('d-none');
 2022        if (previewSpinner) previewSpinner.classList.remove('d-none');
 2023        if (previewTable) previewTable.classList.add('d-none');
 2024        if (filterWrap) filterWrap.classList.add('d-none');
 2025        if (moreNote) moreNote.classList.add('d-none');
 2026
 2027        fetch(syncArchivePreviewUrl(currentReportId))
 2028            .then(r => r.json())
 2029            .then(data => renderArchivePreviewData(data))
 2030            .catch(() => { if (previewSpinner) previewSpinner.classList.add('d-none'); });
 2031    }
 2032
 2033    async function openApplyLog() {
 2034        if (!currentReportId) return;
 2035        applyLogPage = 1;
 2036        // Загружаем список прогонов для фильтра
 2037        try {
 2038            const res  = await fetch(syncApplyLogJobsUrl(currentReportId), { credentials: 'same-origin' });
 2039            const data = await res.json();
 2040            const sel  = document.getElementById('applyLogJobSelect');
 2041            if (sel && data.jobs) {
 2042                sel.innerHTML = '<option value="">Все прогоны</option>' +
 2043                    data.jobs.map(j => `<option value="${escapeHtml(j.jobId)}">${formatUtc(j.appliedAt)}</option>`).join
 2044            }
 2045        } catch (_) {}
 2046        await loadApplyLog();
 2047        bootstrap.Modal.getOrCreateInstance(document.getElementById('syncApplyLogModal')).show();
 2048    }
 2049
 2050    document.getElementById('bitrixSyncApplyLogBtn')?.addEventListener('click', openApplyLog);
 2051    document.getElementById('applyLogRefreshBtn')?.addEventListener('click', () => { applyLogPage = 1; loadApplyLog(); }
 2052    document.getElementById('applyLogPrevPage')?.addEventListener('click', () => { if (applyLogPage > 1) { applyLogPage-
 2053    document.getElementById('applyLogNextPage')?.addEventListener('click', () => { applyLogPage++; loadApplyLog(); });
 2054    document.getElementById('applyLogJobSelect')?.addEventListener('change', () => { applyLogPage = 1; loadApplyLog(); }
 2055    document.getElementById('applyLogArticleFilter')?.addEventListener('keydown', e => { if (e.key === 'Enter') { applyL
 2056    // ── конец журнала ─────────────────────────────────────────────────────────
 2057
 2058    function showArchiveToast(message, isError) {
 2059        const toastEl = document.getElementById('syncArchiveToast');
 2060        const bodyEl = document.getElementById('syncArchiveToastBody');
 2061        if (!toastEl || !bodyEl) return;
 2062        bodyEl.textContent = message;
 2063        toastEl.classList.remove('bg-success', 'bg-danger');
 2064        toastEl.classList.add(isError ? 'bg-danger' : 'bg-success');
 2065        bootstrap.Toast.getOrCreateInstance(toastEl).show();
 2066    }
 2067
 2068    // Кнопки архива/разархива в строках таблицы
 2069    document.getElementById('bitrixSyncReportView')?.addEventListener('click', async e => {
 2070        const unarchiveBtn = e.target.closest('.sync-unarchive-btn');
 2071        const archiveBtn = e.target.closest('.sync-archive-btn');
 2072        const btn = unarchiveBtn || archiveBtn;
 2073        if (!btn || !currentReportId) return;
 2074        const offerId = btn.getAttribute('data-offer-id');
 2075        if (!offerId) return;
 2076        const isUnarchive = !!unarchiveBtn;
 2077        const origText = btn.textContent;
 2078        btn.disabled = true;
 2079        btn.textContent = '…';
 2080        try {
 2081            const url = isUnarchive ? syncReportUnarchiveUrl(currentReportId) : syncReportArchiveUrl(currentReportId);
 2082            const res = await fetch(url, {
 2083                method: 'POST',
 2084                credentials: 'same-origin',
 2085                headers: { 'Content-Type': 'application/json' },
 2086                body: JSON.stringify({ offerIds: [offerId] })
 2087            });
 2088            const data = await readJsonResponse(res);
 2089            if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
 2090
 2091            // Точечно обновляем строки грида с этим offer_id и счётчики архива в сводке.
 2092            if (lastSyncReport) {
 2093                const delta = isUnarchive ? -1 : 1;
 2094                lastSyncReport.archivedCount = Math.max(0, (lastSyncReport.archivedCount || 0) + delta);
 2095            }
 2096            window.BsxReportGrid?.patchByOfferId(offerId, { isArchived: !isUnarchive, isAutoArchived: false });
 2097            renderSummary(lastSyncReport);
 2098            showArchiveToast(
 2099                isUnarchive ? `Товар ${offerId} возвращён из архива` : `Товар ${offerId} отправлен в архив`,
 2100                false
 2101            );
 2102        } catch (err) {
 2103            btn.disabled = false;
 2104            btn.textContent = origText;
 2105            showArchiveToast('Ошибка: ' + err.message, true);
 2106        }
 2107    });
 2108
 2109    // Excel export (background task + progress modal)
 2110    let syncExcelExportPollTimer = null;
 2111    function resetSyncExcelExportSteps() {
 2112        ['Queue', 'Load', 'Build', 'Done'].forEach(name => {
 2113            const row = document.getElementById('bitrixSyncExcelStep' + name);
 2114            if (!row) return;
 2115            row.classList.remove('bsx-step-done', 'bsx-step-active', 'bsx-step-failed');
 2116            const icon = row.querySelector('i');
 2117            if (icon) icon.className = 'far fa-circle text-muted';
 2118        });
 2119    }
 2120    function markSyncExcelStep(name, state) {
 2121        const row = document.getElementById('bitrixSyncExcelStep' + name);
 2122        if (!row) return;
 2123        row.classList.remove('bsx-step-done', 'bsx-step-active', 'bsx-step-failed');
 2124        row.classList.add('bsx-step-' + state);
 2125        const icon = row.querySelector('i');
 2126        if (!icon) return;
 2127        if (state === 'done') icon.className = 'fas fa-check-circle';
 2128        else if (state === 'active') icon.className = 'fas fa-circle-notch fa-spin';
 2129        else if (state === 'failed') icon.className = 'fas fa-exclamation-circle';
 2130        else icon.className = 'far fa-circle text-muted';
 2131    }
 2132    function updateSyncExcelExportModal(status) {
 2133        const stageText = document.getElementById('bitrixSyncExcelExportStageText');
 2134        const details = document.getElementById('bitrixSyncExcelExportDetails');
 2135        const bar = document.getElementById('bitrixSyncExcelExportProgressBar');
 2136        if (stageText) stageText.textContent = status.stage || 'queued';
 2137        if (details) details.textContent = status.message || 'Выполнение задачи…';
 2138        if (bar) {
 2139            const pct = Math.max(0, Math.min(100, Number(status.percent || 0)));
 2140            bar.style.width = pct + '%';
 2141            bar.textContent = pct + '%';
 2142        }
 2143
 2144        markSyncExcelStep('Queue', 'done');
 2145        if (status.stage === 'loading') markSyncExcelStep('Load', 'active');
 2146        if (status.stage === 'building') {
 2147            markSyncExcelStep('Load', 'done');
 2148            markSyncExcelStep('Build', 'active');
 2149        }
 2150        if (status.stage === 'saving') {
 2151            markSyncExcelStep('Load', 'done');
 2152            markSyncExcelStep('Build', 'active');
 2153        }
 2154        if (status.status === 'completed') {
 2155            markSyncExcelStep('Load', 'done');
 2156            markSyncExcelStep('Build', 'done');
 2157            markSyncExcelStep('Done', 'done');
 2158        }
 2159        if (status.status === 'failed') {
 2160            markSyncExcelStep('Done', 'failed');
 2161        }
 2162    }
 2163    document.getElementById('bitrixSyncReportExportBtn')?.addEventListener('click', async () => {
 2164        if (!currentReportId) return;
 2165        const btn = document.getElementById('bitrixSyncReportExportBtn');
 2166        await startSyncReportExcelExport(currentReportId, btn, buildFilterParams(), {
 2167            loadingHtml: '<i class="fas fa-file-excel bsx-excel-icon-spin me-1"></i><span class="bsx-excel-dots">Формиро
 2168            doneHtml: '<i class="fas fa-check me-1"></i>Готово',
 2169            doneClassSwap: ['btn-success', 'btn-outline-success']
 2170        });
 2171    });
 2172
 2173    // Download log
 2174    document.getElementById('bitrixSyncReportDownloadLogBtn')?.addEventListener('click', () => {
 2175        if (!currentReportId) return;
 2176        window.open(syncReportLogUrl(currentReportId), '_blank');
 2177    });
 2178
 2179    // Apply phase
 2180    let syncApplyElapsedTimer = null;
 2181    let syncApplyStartedAt = 0;
 2182
 2183    let _applyProgressPending = null, _applyProgressRaf = null;
 2184    function setApplyProgress(current, total, text) {
 2185        _applyProgressPending = { current, total, text };
 2186        if (!_applyProgressRaf) _applyProgressRaf = requestAnimationFrame(() => {
 2187            _applyProgressRaf = null;
 2188            const p = _applyProgressPending; _applyProgressPending = null;
 2189            const bar = document.getElementById('bitrixSyncApplyProgressBar');
 2190            const txt = document.getElementById('bitrixSyncApplyProgressText');
 2191            if (!bar || !txt) return;
 2192            const pct = p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : 0;
 2193            bar.style.width = pct + '%';
 2194            bar.textContent = pct + '%';
 2195            txt.textContent = p.text || (p.total > 0 ? `Обработано ${p.current} из ${p.total}` : 'Выполнение…');
 2196            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 2197                _par.setProgress(p.current, p.total, p.text);
 2198        });
 2199    }
 2200
 2201    function startApplyElapsed() {
 2202        syncApplyStartedAt = Date.now();
 2203        if (syncApplyElapsedTimer) clearInterval(syncApplyElapsedTimer);
 2204        syncApplyElapsedTimer = setInterval(() => {
 2205            const s = Math.max(0, Math.floor((Date.now() - syncApplyStartedAt) / 1000));
 2206            const el = document.getElementById('bitrixSyncApplyElapsed');
 2207            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 2208        }, 1000);
 2209    }
 2210
 2211    function getSelectedApplyActions() {
 2212        return Array.from(document.querySelectorAll('.bitrix-sync-apply-action-cb:checked'))
 2213            .map(cb => cb.value);
 2214    }
 2215
 2216    async function connectApplySignalR(jobId) {
 2217        if (typeof signalR === 'undefined') throw new Error('SignalR не загружен');
 2218        if (syncApplyConnection) {
 2219            try { await syncApplyConnection.stop(); } catch (_) { }
 2220        }
 2221        syncApplyConnection = new signalR.HubConnectionBuilder()
 2222            .withUrl('/jobProgressHub')
 2223            .withAutomaticReconnect()
 2224            .build();
 2225
 2226        syncApplyConnection.on('Log', msg => appendApplyLog(msg));
 2227        syncApplyConnection.on('Progress', (c, t, text) => setApplyProgress(c, t, text));
 2228        syncApplyConnection.on('Complete', async (success, payload) => {
 2229            const bar = document.getElementById('bitrixSyncApplyProgressBar');
 2230            if (bar) bar.classList.remove('progress-bar-animated');
 2231            if (syncApplyElapsedTimer) clearInterval(syncApplyElapsedTimer);
 2232            const btn = document.getElementById('bitrixSyncApplyStartBtn');
 2233            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-cloud-upload-alt me-1"></i>Синхронизирова
 2234
 2235            _plog(2, 'Complete', { success });
 2236            if (success === false || success === 'false') {
 2237                appendApplyLog('Ошибка: ' + (payload || ''));
 2238                _activeJobStage = 0;
 2239                if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 2240                    _par.setStep(2, 'error');
 2241                    _par.finish(true);
 2242                }
 2243                return;
 2244            }
 2245            appendApplyLog('Готово, обновление таблицы…');
 2246            setTimeout(() => _hideLog(2), 1500);
 2247            if (currentReportId) {
 2248                lastSyncReport = await fetchReport(currentReportId);
 2249                renderSummary(lastSyncReport);
 2250                renderTable(lastSyncReport);
 2251                await loadRunsList();
 2252                document.getElementById('bitrixSyncApplyLogBtn')?.style.setProperty('display', '');
 2253                document.getElementById('bitrixSyncCleanupPreviewBtn')?.removeAttribute('disabled');
 2254                if ((lastSyncReport?.ozonAppliedSuccessCount || 0) > 0) {
 2255                    prefetchCleanupPreviewCache().catch(() => {});
 2256                }
 2257                // Ручной запуск этапа 2: цепочку не продолжаем (это делает оркестратор при запуске пресета).
 2258                _activeJobStage = 0;
 2259            }
 2260        });
 2261        syncApplyConnection.on('Error', msg => appendApplyLog('ОШИБКА: ' + msg));
 2262
 2263        await syncApplyConnection.start();
 2264        await syncApplyConnection.invoke('JoinJobGroup', jobId);
 2265        appendApplyLog('Подключено к журналу синхронизации');
 2266    }
 2267
 2268    // Продолжение флоу после подтверждения инфо-модали (2 клиента)
 2269    async function _continueSyncApply(actions, maxLines, disabledMappings) {
 2270        // Если есть отключенные связи - показать предупреждение
 2271        if (disabledMappings.length > 0) {
 2272            document.getElementById('syncApplyWarningCount').textContent = disabledMappings.length;
 2273            const listEl = document.getElementById('syncApplyWarningMappingList');
 2274            if (listEl) {
 2275                const detailsById = Object.fromEntries((currentReportMappings || []).map(m => [String(m.id), m]));
 2276                listEl.innerHTML = disabledMappings.map(id => {
 2277                    const m = detailsById[String(id)];
 2278                    return m
 2279                        ? `<li><i class="fas fa-link-slash me-1 text-warning"></i><strong>${escapeHtml(m.bitrixWarehouse
 2280                        : `<li><i class="fas fa-link-slash me-1 text-warning"></i>Связь #${id}</li>`;
 2281                }).join('');
 2282                listEl.classList.remove('d-none');
 2283            }
 2284            window.pendingDisabledMappings = disabledMappings;
 2285            window.pendingSyncApplyData = { actions, maxLines };
 2286            new bootstrap.Modal(document.getElementById('syncApplyWarningModal')).show();
 2287            return;
 2288        }
 2289
 2290        // Если есть архивные товары — спросить что с ними делать
 2291        const archivedCount = lastSyncReport?.archivedCount || 0;
 2292        if (archivedCount > 0) {
 2293            document.getElementById('syncApplyArchiveCount').textContent = archivedCount;
 2294            const autoCount = lastSyncReport?.autoArchivedCount || 0;
 2295            const manualCount = archivedCount - autoCount;
 2296            const autoNote = document.getElementById('syncApplyArchiveAutoNote');
 2297            if (autoNote) {
 2298                if (autoCount > 0 || manualCount > 0) {
 2299                    const autoSpan = document.getElementById('syncApplyArchiveAutoCount');
 2300                    const manualSpan = document.getElementById('syncApplyArchiveManualCount');
 2301                    if (autoSpan) autoSpan.textContent = autoCount;
 2302                    if (manualSpan) manualSpan.textContent = manualCount;
 2303                    autoNote.classList.remove('d-none');
 2304                } else {
 2305                    autoNote.classList.add('d-none');
 2306                }
 2307            }
 2308
 2309            // Сбросить радиокнопки к «Все архивные»
 2310            const filterAllRadio = document.getElementById('syncArchiveFilterAll');
 2311            if (filterAllRadio) filterAllRadio.checked = true;
 2312
 2313            loadAndShowArchivePreview();
 2314
 2315            window.pendingSyncApplyData = { actions, maxLines, disabledMappings };
 2316            new bootstrap.Modal(document.getElementById('syncApplyArchiveModal')).show();
 2317            return;
 2318        }
 2319
 2320        await executeSyncApply(actions, maxLines, disabledMappings, 0);
 2321    }
 2322
 2323    async function startSyncApply() {
 2324        if (!currentReportId) { St2Dialog.alert('Сначала откройте отчёт'); return; }
 2325        const actions = getSelectedApplyActions();
 2326        if (!actions.length) { St2Dialog.alert('Выберите хотя бы один тип действия'); return; }
 2327        const maxRaw = document.getElementById('bitrixSyncApplyMaxLines')?.value?.trim();
 2328        const maxLines = maxRaw ? Number(maxRaw) : null;
 2329        if (maxRaw && (!Number.isFinite(maxLines) || maxLines <= 0)) { St2Dialog.alert('Некорректное количество строк');
 2330
 2331        // Собрать отключенные связи
 2332        const savedState = JSON.parse(localStorage.getItem(`bitrixSyncDisabledMappings_${currentReportId}`) || '{}');
 2333        const disabledMappings = Object.entries(savedState)
 2334            .filter(([_, enabled]) => enabled === false)
 2335            .map(([id, _]) => Number(id));
 2336
 2337        // Если 2 клиента — показать инфо-модаль с подтверждением
 2338        if ((lastSyncReport?.multiClientIds?.length || 0) >= 2) {
 2339            window.pendingSyncApplyData = { actions, maxLines, disabledMappings };
 2340            const proceedBtn = document.getElementById('syncPriorityInfoProceedBtn');
 2341            if (proceedBtn) proceedBtn.classList.remove('d-none');
 2342            bootstrap.Modal.getOrCreateInstance(document.getElementById('syncPriorityInfoModal')).show();
 2343            return;
 2344        }
 2345
 2346        await _continueSyncApply(actions, maxLines, disabledMappings);
 2347    }
 2348
 2349    // Кнопка «Продолжить» в инфо-модали
 2350    document.getElementById('syncPriorityInfoProceedBtn')?.addEventListener('click', async () => {
 2351        const modal = bootstrap.Modal.getInstance(document.getElementById('syncPriorityInfoModal'));
 2352        if (modal) modal.hide();
 2353        // Скрываем кнопку «Продолжить» обратно (она показывается только при подтверждении)
 2354        document.getElementById('syncPriorityInfoProceedBtn')?.classList.add('d-none');
 2355        if (!window.pendingSyncApplyData) return;
 2356        const { actions, maxLines, disabledMappings } = window.pendingSyncApplyData;
 2357        delete window.pendingSyncApplyData;
 2358        await _continueSyncApply(actions, maxLines, disabledMappings || []);
 2359    });
 2360
 2361    // Скрываем кнопку «Продолжить» при закрытии модали без подтверждения
 2362    document.getElementById('syncPriorityInfoModal')?.addEventListener('hidden.bs.modal', () => {
 2363        document.getElementById('syncPriorityInfoProceedBtn')?.classList.add('d-none');
 2364    });
 2365
 2366    async function executeSyncApply(actions, maxLines, disabledMappings, archivedMode = 0, unarchiveFilter = 0) {
 2367        if (!currentReportId) return;
 2368        const jobId = newSyncJobId();
 2369        const body = {
 2370            jobId,
 2371            reportId: currentReportId,
 2372            actions,
 2373            maxLines: maxLines || null,
 2374            onlyPending: document.getElementById('bitrixSyncApplyOnlyPending')?.checked !== false,
 2375            disabledWarehouseMappingIds: disabledMappings.length > 0 ? disabledMappings : null,
 2376            archivedMode,
 2377            unarchiveFilter
 2378        };
 2379
 2380        document.getElementById('bitrixSyncApplyProgressWrap')?.classList.remove('d-none');
 2381        _applyLogBuf.length = 0; clearTimeout(_applyLogTimer); _applyLogTimer = null;
 2382        document.getElementById('bitrixSyncApplyLog').innerHTML = '';
 2383        _logStore.clearStage(currentReportId, 2);
 2384        _activeJobStage = 2; _activeJobReportId = currentReportId;
 2385
 2386        const btn = document.getElementById('bitrixSyncApplyStartBtn');
 2387        if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Синх
 2388
 2389        startApplyElapsed();
 2390        setApplyProgress(0, 1, 'Запуск…');
 2391
 2392        try {
 2393            await connectApplySignalR(jobId);
 2394            _par.setJobId(2, jobId);
 2395            const res = await fetch(syncReportApplyStartUrl(currentReportId), {
 2396                method: 'POST',
 2397                headers: { 'Content-Type': 'application/json' },
 2398                body: JSON.stringify(body)
 2399            });
 2400            const data = await readJsonResponse(res);
 2401            if (!res.ok || !data.success) {
 2402                throw new Error(data.error || 'Не удалось запустить синхронизацию');
 2403            }
 2404            appendApplyLog('Задание в очереди');
 2405        } catch (e) {
 2406            appendApplyLog('Ошибка: ' + e.message);
 2407            _activeJobStage = 0;
 2408            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 2409                _par.setStep(2, 'error');
 2410                _par.finish(true);
 2411            }
 2412            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-cloud-upload-alt me-1"></i>Синхронизирова
 2413        }
 2414    }
 2415
 2416    const cleanupPreviewModalState = {
 2417        products: [],
 2418        skipGroups: 0,
 2419        hasMore: false,
 2420        loading: false,
 2421        search: '',
 2422        totalGroups: 0,
 2423        totalWarehouses: 0
 2424    };
 2425    let cleanupPreviewSearchTimer = null;
 2426
 2427    function escAttr(value) {
 2428        return String(value ?? '')
 2429            .replace(/&/g, '&amp;')
 2430            .replace(/"/g, '&quot;')
 2431            .replace(/</g, '&lt;');
 2432    }
 2433
 2434    function cleanupProductKey(p) {
 2435        return `${p.ozonClientId ?? p.OzonClientId}::${(p.article ?? p.Article ?? '').toLowerCase()}`;
 2436    }
 2437
 2438    function getCleanupPreviewFilterOpts(search) {
 2439        const modalOpen = document.getElementById('bitrixSyncCleanupPreviewModal')?.classList.contains('show');
 2440        const onlyPending = modalOpen
 2441            ? document.getElementById('bitrixSyncCleanupModalOnlyPending')?.checked !== false
 2442            : document.getElementById('bitrixSyncCleanupOnlyPending')?.checked !== false;
 2443        const maxRaw = document.getElementById('bitrixSyncCleanupMaxProducts')?.value?.trim();
 2444        return {
 2445            onlyPending,
 2446            maxProducts: maxRaw || '15',
 2447            search: (search ?? cleanupPreviewModalState.search ?? '').trim().toLowerCase()
 2448        };
 2449    }
 2450
 2451    function getCleanupPreviewCacheKey(search) {
 2452        const f = getCleanupPreviewFilterOpts(search);
 2453        return `${f.onlyPending}|${f.maxProducts}|${f.search}`;
 2454    }
 2455
 2456    function isCleanupPreviewCacheValid(reportId, cacheKey) {
 2457        return cleanupPreviewCache.reportId === reportId
 2458            && cleanupPreviewCache.cacheKey === cacheKey
 2459            && cleanupPreviewCache.loadedAt > 0;
 2460    }
 2461
 2462    function invalidateCleanupPreviewCache() {
 2463        cleanupPreviewCache.reportId = null;
 2464        cleanupPreviewCache.cacheKey = null;
 2465        cleanupPreviewCache.products = [];
 2466        cleanupPreviewCache.skipGroups = 0;
 2467        cleanupPreviewCache.hasMore = false;
 2468        cleanupPreviewCache.totalGroups = 0;
 2469        cleanupPreviewCache.totalWarehouses = 0;
 2470        cleanupPreviewCache.loadedAt = 0;
 2471    }
 2472
 2473    function syncCleanupPreviewCacheFromState() {
 2474        if (!currentReportId) return;
 2475        cleanupPreviewCache.reportId = currentReportId;
 2476        cleanupPreviewCache.cacheKey = getCleanupPreviewCacheKey();
 2477        cleanupPreviewCache.products = cleanupPreviewModalState.products;
 2478        cleanupPreviewCache.skipGroups = cleanupPreviewModalState.skipGroups;
 2479        cleanupPreviewCache.hasMore = cleanupPreviewModalState.hasMore;
 2480        cleanupPreviewCache.totalGroups = cleanupPreviewModalState.totalGroups;
 2481        cleanupPreviewCache.totalWarehouses = cleanupPreviewModalState.totalWarehouses;
 2482        cleanupPreviewCache.loadedAt = Date.now();
 2483    }
 2484
 2485    function restoreCleanupPreviewFromCache() {
 2486        cleanupPreviewModalState.products = cleanupPreviewCache.products;
 2487        cleanupPreviewModalState.skipGroups = cleanupPreviewCache.skipGroups;
 2488        cleanupPreviewModalState.hasMore = cleanupPreviewCache.hasMore;
 2489        cleanupPreviewModalState.totalGroups = cleanupPreviewCache.totalGroups;
 2490        cleanupPreviewModalState.totalWarehouses = cleanupPreviewCache.totalWarehouses;
 2491        renderCleanupPreviewTable();
 2492    }
 2493
 2494    function recalcCleanupPreviewTotals() {
 2495        cleanupPreviewModalState.totalWarehouses = cleanupPreviewModalState.products
 2496            .reduce((s, p) => s + (p.warehousesToZero || p.WarehousesToZero || []).length, 0);
 2497        updateCleanupModalSummary();
 2498        renderCleanupCardSummary();
 2499    }
 2500
 2501    function resetCleanupPreviewUi() {
 2502        document.getElementById('bitrixSyncCleanupPreviewSummary')?.classList.add('d-none');
 2503    }
 2504
 2505    function getCleanupPreviewDisplayProducts() {
 2506        if (cleanupPreviewCache.reportId === currentReportId && cleanupPreviewCache.loadedAt > 0) {
 2507            return cleanupPreviewCache.products;
 2508        }
 2509        return cleanupPreviewModalState.products;
 2510    }
 2511
 2512    function renderCleanupCardSummary() {
 2513        const summary = document.getElementById('bitrixSyncCleanupPreviewSummary');
 2514        const startBtn = document.getElementById('bitrixSyncCleanupStartBtn');
 2515        if (!summary) return;
 2516        const products = getCleanupPreviewDisplayProducts();
 2517        const count = products.length;
 2518        const wh = products.reduce((s, p) => s + (p.warehousesToZero || p.WarehousesToZero || []).length, 0);
 2519        if (count > 0) {
 2520            const cached = isCleanupPreviewCacheValid(currentReportId, getCleanupPreviewCacheKey(''));
 2521            const suffix = cached && cleanupPreviewCache.hasMore ? ' (загружено частично)' : '';
 2522            summary.textContent = `В превью: ${count} артикул(ов), ${wh} склад(ов) к обнулению${suffix}`;
 2523            summary.classList.remove('d-none');
 2524            if (startBtn) startBtn.disabled = false;
 2525        } else if (cleanupPreviewCache.loadedAt > 0 && cleanupPreviewCache.reportId === currentReportId) {
 2526            summary.textContent = 'Лишних складов не найдено';
 2527            summary.classList.remove('d-none');
 2528        } else {
 2529            summary.classList.add('d-none');
 2530        }
 2531    }
 2532
 2533    function buildCleanupWarehouseZeroHtml(p, w) {
 2534        const status = (w.cleanupStatus || w.CleanupStatus || '').toLowerCase();
 2535        const done = status === 'success';
 2536        const whId = w.warehouseId ?? w.WarehouseId;
 2537        const whName = w.warehouseName ?? w.WarehouseName ?? '';
 2538        const present = w.present ?? w.Present ?? 0;
 2539        if (done) {
 2540            return `<span class="badge bg-secondary me-1 mb-1" data-wh-id="${whId}">${escapeHtml(whName)}: 0 ✓</span>`;
 2541        }
 2542        return `<span class="badge bg-warning text-dark me-1 mb-1 d-inline-flex align-items-center gap-1" data-wh-id="${
 2543            ${escapeHtml(whName)}: ${present}
 2544            <button type="button" class="btn btn-outline-dark btn-sm py-0 px-1 cleanup-zero-btn"
 2545                title="Обнулить склад"
 2546                data-article="${escAttr(p.article ?? p.Article)}"
 2547                data-client-id="${p.ozonClientId ?? p.OzonClientId}"
 2548                data-offer-id="${escAttr(p.offerId ?? p.OfferId ?? '')}"
 2549                data-product-id="${p.ozonProductId ?? p.OzonProductId ?? 0}"
 2550                data-warehouse-id="${escAttr(whId)}"
 2551                data-warehouse-name="${escAttr(whName)}"
 2552                data-present="${present}">⓪</button>
 2553        </span>`;
 2554    }
 2555
 2556    // Превью этапа 3 — грид AG Grid; строки — cleanupPreviewModalState.products, подгрузка пачками при прокрутке к конц
 2557    const cleanupPreviewTable = window.BsxGrid?.localTable('bitrixSyncCleanupModalTable', {
 2558        key: 'cleanupPreview',
 2559        getRowId: p => cleanupProductKey(p.data),
 2560        gridOptions: {
 2561            getRowHeight: p => {
 2562                const n = Math.max((p.data?.protectedWarehouses || p.data?.ProtectedWarehouses || []).length,
 2563                                   (p.data?.warehousesToZero || p.data?.WarehousesToZero || []).length);
 2564                return Math.max(36, 10 + 26 * Math.ceil(n / 3));
 2565            },
 2566            onBodyScroll: e => {
 2567                if (e.direction !== 'vertical' || !cleanupPreviewModalState.hasMore || cleanupPreviewModalState.loading)
 2568                const last = e.api.getLastDisplayedRowIndex();
 2569                if (last >= e.api.getDisplayedRowCount() - 3) fetchCleanupPreviewBatch(false).catch(() => {});
 2570            }
 2571        },
 2572        columnDefs: [
 2573            { colId: 'article', headerName: 'Артикул', width: 170, pinned: 'left', cellClass: 'fw-semibold', valueGetter
 2574            { colId: 'client', headerName: 'Клиент', width: 140, valueGetter: p => p.data?.ozonClientName ?? p.data?.Ozo
 2575              filter: window.BsxGrid?.SetFilter, floatingFilterComponent: window.BsxGrid?.SetFloatingFilter },
 2576            { colId: 'protected', headerName: 'Сохранить (qty > 0)', flex: 1, minWidth: 220, sortable: false, cellClass:
 2577              valueGetter: p => (p.data?.protectedWarehouses || p.data?.ProtectedWarehouses || []).map(w => w.warehouseN
 2578              cellRenderer: p => (p.data?.protectedWarehouses || p.data?.ProtectedWarehouses || [])
 2579                  .map(w => `<span class="badge bg-success me-1 mb-1">${escapeHtml(w.warehouseName ?? w.WarehouseName)}:
 2580                  .join('') || '<span class="text-muted">—</span>' },
 2581            { colId: 'toZero', headerName: 'Обнулить (FBS)', flex: 1, minWidth: 240, sortable: false, cellClass: 'bsx-ce
 2582              valueGetter: p => (p.data?.warehousesToZero || p.data?.WarehousesToZero || []).map(w => w.warehouseName ??
 2583              cellRenderer: p => (p.data?.warehousesToZero || p.data?.WarehousesToZero || [])
 2584                  .map(w => buildCleanupWarehouseZeroHtml(p.data, w)).join('') || '<span class="text-muted">—</span>' }
 2585        ]
 2586    });
 2587
 2588    /** Грид превью этапа 3 ← состояние (после пачки, обнуления склада, фильтра). */
 2589    function syncCleanupPreviewGrid() {
 2590        if (!cleanupPreviewTable) return;
 2591        const products = cleanupPreviewModalState.products;
 2592        if (!products.length && cleanupPreviewCache.loadedAt > 0) {
 2593            cleanupPreviewTable.message(cleanupPreviewModalState.totalGroups > 0
 2594                ? 'В проверенных группах лишних складов нет. Прокрутите вниз или измените фильтр.'
 2595                : 'Нет артикулов с остатком > 0 для этапа 3');
 2596        } else {
 2597            cleanupPreviewTable.setRows(products.slice());
 2598        }
 2599        recalcCleanupPreviewTotals();
 2600    }
 2601
 2602    function updateCleanupModalSummary() {
 2603        const el = document.getElementById('bitrixSyncCleanupModalSummary');
 2604        if (!el) return;
 2605        const n = cleanupPreviewModalState.products.length;
 2606        const wh = cleanupPreviewModalState.totalWarehouses;
 2607        const total = cleanupPreviewModalState.totalGroups;
 2608        const scanned = cleanupPreviewModalState.skipGroups;
 2609        let text = n > 0 ? `Показано ${n} артикул(ов), ${wh} склад(ов)` : 'Лишних складов не найдено';
 2610        if (total > 0) text += ` · проверено ${scanned} из ${total} групп`;
 2611        if (cleanupPreviewModalState.hasMore) text += ' · прокрутите вниз для подгрузки';
 2612        el.textContent = text;
 2613    }
 2614
 2615
 2616    function renderCleanupPreviewTable() { syncCleanupPreviewGrid(); }
 2617
 2618    function appendCleanupPreviewProducts() { syncCleanupPreviewGrid(); }
 2619
 2620
 2621    function rerenderCleanupPreviewRow() { syncCleanupPreviewGrid(); }
 2622
 2623    function removeCleanupPreviewProduct(clientId, article) {
 2624        const key = `${clientId}::${(article || '').toLowerCase()}`;
 2625        cleanupPreviewModalState.products = cleanupPreviewModalState.products
 2626            .filter(p => cleanupProductKey(p) !== key);
 2627        syncCleanupPreviewGrid();
 2628    }
 2629
 2630    function mergeCleanupPreviewProduct(updated) {
 2631        if (!updated) return;
 2632        const key = cleanupProductKey(updated);
 2633        const idx = cleanupPreviewModalState.products.findIndex(p => cleanupProductKey(p) === key);
 2634        const zeroList = updated.warehousesToZero || updated.WarehousesToZero || [];
 2635        if (!zeroList.length) {
 2636            if (idx >= 0) {
 2637                const art = updated.article ?? updated.Article;
 2638                const cid = updated.ozonClientId ?? updated.OzonClientId;
 2639                removeCleanupPreviewProduct(cid, art);
 2640            }
 2641            return;
 2642        }
 2643        if (idx >= 0) cleanupPreviewModalState.products[idx] = updated;
 2644        else cleanupPreviewModalState.products.push(updated);
 2645        rerenderCleanupPreviewRow(updated.ozonClientId ?? updated.OzonClientId, updated.article ?? updated.Article);
 2646    }
 2647
 2648    function applyCleanupPreviewZeroPatch(clientId, article, warehouseId, whName) {
 2649        const p = cleanupPreviewModalState.products.find(x =>
 2650            (x.ozonClientId ?? x.OzonClientId) === clientId
 2651            && String(x.article ?? x.Article).toLowerCase() === (article || '').toLowerCase());
 2652        if (!p) return;
 2653        const wh = (p.warehousesToZero || p.WarehousesToZero || [])
 2654            .find(w => String(w.warehouseId ?? w.WarehouseId) === warehouseId);
 2655        if (wh) {
 2656            wh.cleanupStatus = wh.CleanupStatus = 'Success';
 2657            wh.present = wh.Present = 0;
 2658        }
 2659        const onlyPending = document.getElementById('bitrixSyncCleanupModalOnlyPending')?.checked !== false;
 2660        const zeroList = p.warehousesToZero || p.WarehousesToZero || [];
 2661        const allDone = zeroList.length > 0 && zeroList.every(w =>
 2662            (w.cleanupStatus ?? w.CleanupStatus ?? '').toLowerCase() === 'success');
 2663        if (onlyPending && allDone) {
 2664            removeCleanupPreviewProduct(clientId, article);
 2665        } else {
 2666            rerenderCleanupPreviewRow(clientId, article);
 2667        }
 2668        syncCleanupPreviewCacheFromState();
 2669    }
 2670
 2671    async function refreshCleanupPreviewArticle(article, clientId) {
 2672        if (!currentReportId || !article) return;
 2673        const f = getCleanupPreviewFilterOpts();
 2674        const params = new URLSearchParams({
 2675            onlyPending: String(f.onlyPending),
 2676            maxProducts: f.maxProducts,
 2677            skipGroups: '0'
 2678        });
 2679        if (f.search) params.set('search', f.search);
 2680        params.append('articles', article);
 2681        try {
 2682            const res = await fetch(`${syncReportCleanupPreviewUrl(currentReportId)}?${params}`);
 2683            const data = await readJsonResponse(res);
 2684            if (!res.ok || !data.success) return;
 2685            const batch = data.preview?.products || data.preview?.Products || [];
 2686            if (batch.length) mergeCleanupPreviewProduct(batch[0]);
 2687            else removeCleanupPreviewProduct(clientId, article);
 2688            syncCleanupPreviewCacheFromState();
 2689        } catch (_) { /* фоновое обновление — без алерта */ }
 2690    }
 2691
 2692    function setCleanupModalLoading(on) {
 2693        cleanupPreviewModalState.loading = on;
 2694        document.getElementById('bitrixSyncCleanupModalLoader')?.classList.toggle('d-none', !on);
 2695    }
 2696
 2697    async function fetchCleanupPreviewBatch(reset) {
 2698        if (!currentReportId || cleanupPreviewModalState.loading) return;
 2699        if (!reset && !cleanupPreviewModalState.hasMore) return;
 2700
 2701        if (reset) {
 2702            cleanupPreviewModalState.products = [];
 2703            cleanupPreviewModalState.skipGroups = 0;
 2704            cleanupPreviewModalState.hasMore = false;
 2705            cleanupPreviewModalState.totalGroups = 0;
 2706            cleanupPreviewModalState.totalWarehouses = 0;
 2707            cleanupPreviewTable?.loading(true);
 2708        }
 2709
 2710        setCleanupModalLoading(true);
 2711        try {
 2712            const onlyPending = document.getElementById('bitrixSyncCleanupModalOnlyPending')?.checked !== false;
 2713            const maxRaw = document.getElementById('bitrixSyncCleanupMaxProducts')?.value?.trim();
 2714            const params = new URLSearchParams({
 2715                onlyPending: String(onlyPending),
 2716                maxProducts: maxRaw || '15',
 2717                skipGroups: String(cleanupPreviewModalState.skipGroups)
 2718            });
 2719            const search = (cleanupPreviewModalState.search || '').trim();
 2720            if (search) params.set('search', search);
 2721
 2722            const res = await fetch(`${syncReportCleanupPreviewUrl(currentReportId)}?${params}`);
 2723            const data = await readJsonResponse(res);
 2724            if (!res.ok || !data.success) throw new Error(data.error || 'Ошибка превью');
 2725
 2726            const preview = data.preview;
 2727            const batch = preview?.products || preview?.Products || [];
 2728            if (reset) {
 2729                cleanupPreviewModalState.products = batch.slice();
 2730            } else {
 2731                const seen = new Set(cleanupPreviewModalState.products.map(p =>
 2732                    `${p.ozonClientId ?? p.OzonClientId}::${(p.article ?? p.Article ?? '').toLowerCase()}`));
 2733                for (const p of batch) {
 2734                    const key = `${p.ozonClientId ?? p.OzonClientId}::${(p.article ?? p.Article ?? '').toLowerCase()}`;
 2735                    if (!seen.has(key)) {
 2736                        seen.add(key);
 2737                        cleanupPreviewModalState.products.push(p);
 2738                    }
 2739                }
 2740            }
 2741            cleanupPreviewModalState.skipGroups = preview?.nextSkipGroups ?? preview?.NextSkipGroups ?? cleanupPreviewMo
 2742            cleanupPreviewModalState.hasMore = !!(preview?.hasMore ?? preview?.HasMore);
 2743            cleanupPreviewModalState.totalGroups = preview?.totalGroups ?? preview?.TotalGroups ?? cleanupPreviewModalSt
 2744            syncCleanupPreviewCacheFromState();
 2745
 2746            syncCleanupPreviewGrid();
 2747        } catch (e) {
 2748            if (reset) cleanupPreviewTable?.message('Ошибка: ' + e.message);
 2749        } finally {
 2750            setCleanupModalLoading(false);
 2751        }
 2752    }
 2753
 2754    // Подгрузка следующей пачки — onBodyScroll грида превью (см. cleanupPreviewTable).
 2755    function setupCleanupPreviewObserver() { }
 2756
 2757    async function openCleanupPreviewModal(forceRefresh = false) {
 2758        if (!currentReportId) return;
 2759        const modalEl = document.getElementById('bitrixSyncCleanupPreviewModal');
 2760        if (!modalEl) return;
 2761        const searchEl = document.getElementById('bitrixSyncCleanupModalSearch');
 2762        const pendingEl = document.getElementById('bitrixSyncCleanupModalOnlyPending');
 2763        const cardPending = document.getElementById('bitrixSyncCleanupOnlyPending');
 2764        if (searchEl) searchEl.value = cleanupPreviewModalState.search || '';
 2765        if (pendingEl && cardPending) pendingEl.checked = cardPending.checked;
 2766        bootstrap.Modal.getOrCreateInstance(modalEl).show();
 2767        const cacheKey = getCleanupPreviewCacheKey(cleanupPreviewModalState.search);
 2768        if (!forceRefresh && isCleanupPreviewCacheValid(currentReportId, cacheKey)) {
 2769            restoreCleanupPreviewFromCache();
 2770            setupCleanupPreviewObserver();
 2771            return;
 2772        }
 2773        await fetchCleanupPreviewBatch(true);
 2774        setupCleanupPreviewObserver();
 2775    }
 2776
 2777    async function prefetchCleanupPreviewCache() {
 2778        if (!currentReportId) return;
 2779        const cacheKey = getCleanupPreviewCacheKey('');
 2780        if (isCleanupPreviewCacheValid(currentReportId, cacheKey) && !cleanupPreviewCache.hasMore) {
 2781            renderCleanupCardSummary();
 2782            return;
 2783        }
 2784        cleanupPreviewModalState.search = '';
 2785        await fetchCleanupPreviewBatch(true);
 2786        renderCleanupCardSummary();
 2787    }
 2788
 2789    async function zeroCleanupWarehouseFromPreview(btn) {
 2790        if (!currentReportId || !btn || btn.disabled) return;
 2791        const article = btn.getAttribute('data-article');
 2792        const clientId = Number(btn.getAttribute('data-client-id'));
 2793        const warehouseId = (btn.getAttribute('data-warehouse-id') || '').trim();
 2794        const offerId = btn.getAttribute('data-offer-id') || '';
 2795        const productId = Number(btn.getAttribute('data-product-id') || 0);
 2796        const present = Number(btn.getAttribute('data-present') || 0);
 2797        const whName = btn.getAttribute('data-warehouse-name') || '';
 2798        if (!article || !clientId || !warehouseId) {
 2799            St2Dialog.alert('Не удалось прочитать данные склада. Обновите превью и повторите.', { variant: 'warning' });
 2800            return;
 2801        }
 2802        if (!(await St2Dialog.confirm(`Обнулить склад «${whName}» для ${article}?`, { variant: 'danger', confirmText: 'О
 2803
 2804        btn.disabled = true;
 2805        btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
 2806        try {
 2807            const res = await fetch(syncReportCleanupZeroUrl(currentReportId), {
 2808                method: 'POST',
 2809                headers: { 'Content-Type': 'application/json' },
 2810                body: JSON.stringify({
 2811                    ozonClientId: clientId,
 2812                    article,
 2813                    warehouseId,
 2814                    offerId,
 2815                    ozonProductId: productId,
 2816                    present,
 2817                    warehouseName: whName
 2818                })
 2819            });
 2820            const data = await readJsonResponse(res);
 2821            if (!res.ok || !data.success) throw new Error(data.error || 'Ошибка обнуления');
 2822
 2823            applyCleanupPreviewZeroPatch(clientId, article, warehouseId, whName);
 2824            if (currentReportId) {
 2825                lastSyncReport = await fetchReport(currentReportId);
 2826                renderSummary(lastSyncReport);
 2827                // Показать в гриде строки артикула (включая новую строку ⓪₃ этапа 3).
 2828                window.BsxReportGrid?.searchFor(article);
 2829            }
 2830            const written = data.result?.reportLineWritten ?? data.result?.ReportLineWritten;
 2831            if (written === false) {
 2832                St2Dialog.alert((data.result?.error || data.result?.Error || 'Строка отчёта не записана'), { variant: 'w
 2833            } else if (typeof St2Dialog?.toast === 'function') {
 2834                St2Dialog.toast(`Склад «${whName}» обнулён — строка добавлена в отчёт`, 'success');
 2835            }
 2836            refreshCleanupPreviewArticle(article, clientId).catch(() => {});
 2837        } catch (e) {
 2838            St2Dialog.alert('Ошибка: ' + e.message);
 2839            btn.disabled = false;
 2840            btn.textContent = '⓪';
 2841        }
 2842    }
 2843
 2844    document.getElementById('bitrixSyncCleanupModalTable')?.addEventListener('click', e => {
 2845        const btn = e.target.closest('.cleanup-zero-btn');
 2846        if (btn) zeroCleanupWarehouseFromPreview(btn).catch(() => {});
 2847    });
 2848
 2849    document.getElementById('bitrixSyncCleanupModalSearch')?.addEventListener('input', e => {
 2850        cleanupPreviewModalState.search = e.target.value || '';
 2851        clearTimeout(cleanupPreviewSearchTimer);
 2852        cleanupPreviewSearchTimer = setTimeout(() => fetchCleanupPreviewBatch(true).catch(() => {}), 400);
 2853    });
 2854
 2855    document.getElementById('bitrixSyncCleanupModalOnlyPending')?.addEventListener('change', () => {
 2856        invalidateCleanupPreviewCache();
 2857        fetchCleanupPreviewBatch(true).catch(() => {});
 2858    });
 2859
 2860    document.getElementById('bitrixSyncCleanupModalRefreshBtn')?.addEventListener('click', () => {
 2861        invalidateCleanupPreviewCache();
 2862        fetchCleanupPreviewBatch(true).catch(() => {});
 2863    });
 2864
 2865    document.getElementById('bitrixSyncCleanupOnlyPending')?.addEventListener('change', () => {
 2866        invalidateCleanupPreviewCache();
 2867        resetCleanupPreviewUi();
 2868    });
 2869
 2870    document.getElementById('bitrixSyncCleanupModalRunAllBtn')?.addEventListener('click', () => {
 2871        bootstrap.Modal.getInstance(document.getElementById('bitrixSyncCleanupPreviewModal'))?.hide();
 2872        startCleanupApply().catch(() => {});
 2873    });
 2874
 2875    async function loadCleanupPreview() {
 2876        const btn = document.getElementById('bitrixSyncCleanupPreviewBtn');
 2877        if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…'; 
 2878        try {
 2879            await openCleanupPreviewModal();
 2880        } finally {
 2881            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-search me-1"></i>Превью'; }
 2882        }
 2883    }
 2884
 2885    let _cleanupLogBuf = [], _cleanupLogTimer = null;
 2886    function appendCleanupLog(msg) {
 2887        _cleanupLogBuf.push(msg);
 2888        if (!_cleanupLogTimer) _cleanupLogTimer = setTimeout(() => {
 2889            _cleanupLogTimer = null;
 2890            _flushLog('bitrixSyncCleanupLog', _cleanupLogBuf);
 2891        }, 80);
 2892        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) _par.log(msg);
 2893        _logStore.push(currentReportId, 3, msg);
 2894    }
 2895
 2896    function startCleanupElapsed() {
 2897        syncCleanupStartedAt = Date.now();
 2898        if (syncCleanupElapsedTimer) clearInterval(syncCleanupElapsedTimer);
 2899        syncCleanupElapsedTimer = setInterval(() => {
 2900            const s = Math.max(0, Math.floor((Date.now() - syncCleanupStartedAt) / 1000));
 2901            const el = document.getElementById('bitrixSyncCleanupElapsed');
 2902            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 2903        }, 1000);
 2904    }
 2905
 2906    let _cleanupProgressPending = null, _cleanupProgressRaf = null;
 2907    function setCleanupProgress(current, total, text) {
 2908        _cleanupProgressPending = { current, total, text };
 2909        if (!_cleanupProgressRaf) _cleanupProgressRaf = requestAnimationFrame(() => {
 2910            _cleanupProgressRaf = null;
 2911            const p = _cleanupProgressPending; _cleanupProgressPending = null;
 2912            const bar = document.getElementById('bitrixSyncCleanupProgressBar');
 2913            const txt = document.getElementById('bitrixSyncCleanupProgressText');
 2914            if (!bar || !txt) return;
 2915            const pct = p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : 0;
 2916            bar.style.width = pct + '%';
 2917            bar.textContent = pct + '%';
 2918            txt.textContent = p.text || (p.total > 0 ? `Обработано ${p.current} из ${p.total}` : 'Выполнение…');
 2919            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 2920                _par.setProgress(p.current, p.total, p.text);
 2921        });
 2922    }
 2923
 2924    async function connectCleanupSignalR(jobId) {
 2925        if (typeof signalR === 'undefined') throw new Error('SignalR не загружен');
 2926        if (syncCleanupConnection) {
 2927            try { await syncCleanupConnection.stop(); } catch (_) { }
 2928        }
 2929        syncCleanupConnection = new signalR.HubConnectionBuilder()
 2930            .withUrl('/jobProgressHub')
 2931            .withAutomaticReconnect()
 2932            .build();
 2933        syncCleanupConnection.on('Log', msg => appendCleanupLog(msg));
 2934        syncCleanupConnection.on('Progress', (c, t, text) => setCleanupProgress(c, t, text));
 2935        syncCleanupConnection.on('Complete', async (success, payload) => {
 2936            const bar = document.getElementById('bitrixSyncCleanupProgressBar');
 2937            if (bar) bar.classList.remove('progress-bar-animated');
 2938            if (syncCleanupElapsedTimer) clearInterval(syncCleanupElapsedTimer);
 2939            const btn = document.getElementById('bitrixSyncCleanupStartBtn');
 2940            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-broom me-1"></i>Запустить очистку'; }
 2941            if (success === false || success === 'false') {
 2942                appendCleanupLog('Ошибка: ' + (payload || ''));
 2943                _activeJobStage = 0;
 2944                if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 2945                    _par.setStep(3, 'error');
 2946                    _par.finish(true);
 2947                }
 2948                return;
 2949            }
 2950            appendCleanupLog('Готово, обновление таблицы…');
 2951            setTimeout(() => _hideLog(3), 1500);
 2952            if (currentReportId) {
 2953                invalidateCleanupPreviewCache();
 2954                lastSyncReport = await fetchReport(currentReportId);
 2955                renderSummary(lastSyncReport);
 2956                renderTable(lastSyncReport);
 2957                prefetchCleanupPreviewCache().catch(() => {});
 2958                await loadRunsList();
 2959            }
 2960            // Ручной запуск этапа 3: цепочку не продолжаем (это делает оркестратор при запуске пресета).
 2961            _activeJobStage = 0;
 2962            _plog(3, 'Complete', { success });
 2963        });
 2964        syncCleanupConnection.on('Error', msg => appendCleanupLog('ОШИБКА: ' + msg));
 2965        await syncCleanupConnection.start();
 2966        await syncCleanupConnection.invoke('JoinJobGroup', jobId);
 2967        appendCleanupLog('Подключено к журналу очистки');
 2968    }
 2969
 2970    async function executeCleanupApply(onlyPending = true) {
 2971        if (!currentReportId) return;
 2972        if ((lastSyncReport?.ozonAppliedSuccessCount || 0) <= 0) return;
 2973        const jobId = newSyncJobId();
 2974        const body = {
 2975            jobId,
 2976            reportId: currentReportId,
 2977            onlyPending,
 2978            maxProducts: null
 2979        };
 2980        document.getElementById('bitrixSyncCleanupProgressWrap')?.classList.remove('d-none');
 2981        _cleanupLogBuf.length = 0; clearTimeout(_cleanupLogTimer); _cleanupLogTimer = null;
 2982        document.getElementById('bitrixSyncCleanupLog').innerHTML = '';
 2983        _logStore.clearStage(currentReportId, 3);
 2984        _activeJobStage = 3; _activeJobReportId = currentReportId;
 2985        const btn = document.getElementById('bitrixSyncCleanupStartBtn');
 2986        if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Очис
 2987        startCleanupElapsed();
 2988        setCleanupProgress(0, 1, 'Запуск…');
 2989        try {
 2990            await connectCleanupSignalR(jobId);
 2991            _par.setJobId(3, jobId);
 2992            const res = await fetch(syncReportCleanupStartUrl(currentReportId), {
 2993                method: 'POST',
 2994                headers: { 'Content-Type': 'application/json' },
 2995                body: JSON.stringify(body)
 2996            });
 2997            const data = await readJsonResponse(res);
 2998            if (!res.ok || !data.success) throw new Error(data.error || 'Не удалось запустить очистку');
 2999            appendCleanupLog('Задание в очереди');
 3000        } catch (e) {
 3001            appendCleanupLog('Ошибка: ' + e.message);
 3002            _activeJobStage = 0;
 3003            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 3004                _par.setStep(3, 'error');
 3005                _par.finish(true);
 3006            }
 3007            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-broom me-1"></i>Запустить очистку'; }
 3008        }
 3009    }
 3010
 3011    async function startCleanupApply() {
 3012        if (!currentReportId) { St2Dialog.alert('Сначала откройте отчёт'); return; }
 3013        if ((lastSyncReport?.ozonAppliedSuccessCount || 0) <= 0) {
 3014            St2Dialog.alert('Сначала выполните этап 2 (накат в Ozon)');
 3015            return;
 3016        }
 3017        if (!(await St2Dialog.confirm('Запустить очистку лишних FBS-складов по всем артикулам отчёта? Задание выполняетс
 3018        const onlyPending = document.getElementById('bitrixSyncCleanupOnlyPending')?.checked !== false;
 3019        await executeCleanupApply(onlyPending);
 3020    }
 3021
 3022    document.getElementById('bitrixSyncCleanupPreviewBtn')?.addEventListener('click', () => loadCleanupPreview());
 3023    document.getElementById('bitrixSyncCleanupStartBtn')?.addEventListener('click', startCleanupApply);
 3024
 3025    // ── Этап 4: Накат цен ─────────────────────────────────────────────────
 3026    let _pricePushLogBuf = [], _pricePushLogTimer = null;
 3027    let _priceIndexLogBuf = [], _priceIndexLogTimer = null;
 3028
 3029    function appendPricePushLog(msg) {
 3030        _pricePushLogBuf.push(msg);
 3031        if (!_pricePushLogTimer) _pricePushLogTimer = setTimeout(() => {
 3032            _pricePushLogTimer = null;
 3033            _flushLog('bitrixSyncPricePushLog', _pricePushLogBuf);
 3034        }, 80);
 3035        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) _par.log(msg);
 3036        _logStore.push(currentReportId, 4, msg);
 3037    }
 3038
 3039    function setPricePushProgress(c, t, text) {
 3040        const bar = document.getElementById('bitrixSyncPricePushProgressBar');
 3041        const txt = document.getElementById('bitrixSyncPricePushProgressText');
 3042        const pct = t > 0 ? Math.min(100, Math.round((c / t) * 100)) : (c > 0 ? 100 : 0);
 3043        if (bar) { bar.style.width = pct + '%'; bar.textContent = pct + '%'; }
 3044        if (txt && text) txt.textContent = text;
 3045        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 3046            _par.setProgress(c, t, text);
 3047    }
 3048
 3049    function startPricePushElapsed() {
 3050        syncPricePushStartedAt = Date.now();
 3051        if (syncPricePushElapsedTimer) clearInterval(syncPricePushElapsedTimer);
 3052        syncPricePushElapsedTimer = setInterval(() => {
 3053            const s = Math.max(0, Math.floor((Date.now() - syncPricePushStartedAt) / 1000));
 3054            const el = document.getElementById('bitrixSyncPricePushElapsed');
 3055            if (el) el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
 3056        }, 1000);
 3057    }
 3058
 3059    async function connectPricePushSignalR(jobId) {
 3060        if (typeof signalR === 'undefined') throw new Error('SignalR не загружен');
 3061        if (syncPricePushConnection) {
 3062            try { await syncPricePushConnection.stop(); } catch (_) {}
 3063        }
 3064        syncPricePushConnection = new signalR.HubConnectionBuilder()
 3065            .withUrl('/jobProgressHub')
 3066            .withAutomaticReconnect()
 3067            .build();
 3068        syncPricePushConnection.on('Log', msg => appendPricePushLog(msg));
 3069        syncPricePushConnection.on('Progress', (c, t, text) => setPricePushProgress(c, t, text));
 3070        syncPricePushConnection.on('Complete', async (success, payload) => {
 3071            const bar = document.getElementById('bitrixSyncPricePushProgressBar');
 3072            if (bar) bar.classList.remove('progress-bar-animated');
 3073            if (syncPricePushElapsedTimer) clearInterval(syncPricePushElapsedTimer);
 3074            const btn = document.getElementById('bitrixSyncPricePushStartBtn');
 3075            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-tag me-1"></i>Накатить цены'; }
 3076            if (success === false || success === 'false') {
 3077                appendPricePushLog('Ошибка: ' + (payload || ''));
 3078                _activeJobStage = 0;
 3079                if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 3080                    _par.setStep(4, 'error');
 3081                    _par.finish(true);
 3082                }
 3083                return;
 3084            }
 3085            _plog(4, 'Complete', { success, pushedCount: payload?.pushedCount, scheduledIndexJobId: payload?.scheduledIn
 3086            appendPricePushLog('Готово, обновление таблицы…');
 3087            setTimeout(() => _hideLog(4), 1500);
 3088            if (currentReportId) {
 3089                lastSyncReport = await fetchReport(currentReportId);
 3090                renderSummary(lastSyncReport);
 3091                renderTable(lastSyncReport);
 3092                try { await loadSyncReportPriceInfo(currentReportId); } catch (_) {}
 3093                await loadRunsList();
 3094            }
 3095            _activeJobStage = 0;
 3096            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 3097                _par.setStep(4, 'done');
 3098                _par.finish(false);
 3099            }
 3100            // Фаза 4c: запланировано обновление индексов через 5 мин
 3101            const indexJobId = payload?.scheduledIndexJobId ?? null;
 3102            _plog(4, '4cCheck', { indexJobId });
 3103            if (indexJobId) {
 3104                const wrap = document.getElementById('bitrixSyncPriceIndexWrap');
 3105                if (wrap) wrap.classList.remove('d-none');
 3106                syncPriceIndexStartedAt = Date.now();
 3107                if (syncPriceIndexElapsedTimer) clearInterval(syncPriceIndexElapsedTimer);
 3108                syncPriceIndexElapsedTimer = setInterval(() => {
 3109                    const s = Math.max(0, Math.floor((Date.now() - syncPriceIndexStartedAt) / 1000));
 3110                    const el = document.getElementById('bitrixSyncPriceIndexElapsed');
 3111                    if (el) el.textContent = String(Math.floor(s / 60)).padStart(2,'0') + ':' + String(s % 60).padStart(
 3112                }, 1000);
 3113                setTimeout(() => connectPriceIndexSignalR(indexJobId).catch(e => {
 3114                    const log = document.getElementById('bitrixSyncPriceIndexLog');
 3115                    if (log) log.textContent = 'Ошибка подключения: ' + e.message;
 3116                }), 300);
 3117            }
 3118        });
 3119        syncPricePushConnection.on('Error', msg => appendPricePushLog('ОШИБКА: ' + msg));
 3120        await syncPricePushConnection.start();
 3121        await syncPricePushConnection.invoke('JoinJobGroup', jobId);
 3122        appendPricePushLog('Подключено к журналу наката цен');
 3123    }
 3124
 3125    async function executePricePush(onlyPending = true) {
 3126        if (!currentReportId) return;
 3127        const jobId = newSyncJobId();
 3128        const body = { jobId, reportId: currentReportId, onlyPending };
 3129        document.getElementById('bitrixSyncPricePushProgressWrap')?.classList.remove('d-none');
 3130        _pricePushLogBuf.length = 0; clearTimeout(_pricePushLogTimer); _pricePushLogTimer = null;
 3131        document.getElementById('bitrixSyncPricePushLog').innerHTML = '';
 3132        _logStore.clearStage(currentReportId, 4);
 3133        _activeJobStage = 4; _activeJobReportId = currentReportId;
 3134        const btn = document.getElementById('bitrixSyncPricePushStartBtn');
 3135        if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Нака
 3136        startPricePushElapsed();
 3137        setPricePushProgress(0, 1, 'Запуск…');
 3138        _plog(4, 'ExecutePricePushStart', { jobId, reportId: currentReportId });
 3139        try {
 3140            await connectPricePushSignalR(jobId);
 3141            _par.setJobId(4, jobId);
 3142            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 3143                _par.setStep(4, 'active');
 3144                document.getElementById('presetAutoRunStageText').textContent = 'Этап 4 — накат цен';
 3145            }
 3146            const res = await fetch(syncReportPricePushStartUrl(currentReportId), {
 3147                method: 'POST',
 3148                headers: { 'Content-Type': 'application/json' },
 3149                body: JSON.stringify(body)
 3150            });
 3151            const data = await readJsonResponse(res);
 3152            if (!res.ok || !data.success) throw new Error(data.error || 'Не удалось запустить накат цен');
 3153            appendPricePushLog('Задание в очереди');
 3154        } catch (e) {
 3155            appendPricePushLog('Ошибка: ' + e.message);
 3156            _activeJobStage = 0;
 3157            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 3158                _par.setStep(4, 'error');
 3159                _par.finish(true);
 3160            }
 3161            if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-tag me-1"></i>Накатить цены'; }
 3162        }
 3163    }
 3164
 3165    // ── Фаза 4c: обновление индексов ─────────────────────────────────────────
 3166    async function connectPriceIndexSignalR(jobId) {
 3167        if (typeof signalR === 'undefined') return;
 3168        if (syncPriceIndexConnection) {
 3169            try { await syncPriceIndexConnection.stop(); } catch (_) {}
 3170        }
 3171        _priceIndexLogBuf.length = 0; clearTimeout(_priceIndexLogTimer); _priceIndexLogTimer = null;
 3172        document.getElementById('bitrixSyncPriceIndexLog').innerHTML = '';
 3173        syncPriceIndexConnection = new signalR.HubConnectionBuilder()
 3174            .withUrl('/jobProgressHub')
 3175            .withAutomaticReconnect()
 3176            .build();
 3177
 3178        syncPriceIndexConnection.on('Log', msg => {
 3179            _priceIndexLogBuf.push(msg);
 3180            if (!_priceIndexLogTimer) _priceIndexLogTimer = setTimeout(() => {
 3181                _priceIndexLogTimer = null;
 3182                _flushLog('bitrixSyncPriceIndexLog', _priceIndexLogBuf);
 3183            }, 80);
 3184        });
 3185        syncPriceIndexConnection.on('Progress', (c, t, text) => {
 3186            const bar = document.getElementById('bitrixSyncPriceIndexBar');
 3187            const txt = document.getElementById('bitrixSyncPriceIndexText');
 3188            const pct = t > 0 ? Math.min(100, Math.round((c / t) * 100)) : 100;
 3189            if (bar) { bar.style.width = pct + '%'; bar.classList.remove('progress-bar-animated'); bar.classList.add('pr
 3190            if (txt && text) txt.textContent = text;
 3191        });
 3192        syncPriceIndexConnection.on('Complete', async (success, payload4c) => {
 3193            if (syncPriceIndexElapsedTimer) clearInterval(syncPriceIndexElapsedTimer);
 3194            const bar = document.getElementById('bitrixSyncPriceIndexBar');
 3195            const txt = document.getElementById('bitrixSyncPriceIndexText');
 3196            if (bar) { bar.style.width = '100%'; bar.classList.remove('progress-bar-animated'); }
 3197            if (success === false || success === 'false') {
 3198                if (txt) txt.textContent = '⚠ Ошибка обновления индексов';
 3199                return;
 3200            }
 3201            const cnt = payload4c?.updatedCount ?? 0;
 3202            if (txt) txt.textContent = `✓ Индексы обновлены: ${cnt}`;
 3203            if (currentReportId) {
 3204                try { await loadSyncReportPriceInfo(currentReportId); } catch (_) {}
 3205                lastSyncReport = await fetchReport(currentReportId);
 3206                renderTable(lastSyncReport);
 3207            }
 3208            setTimeout(() => {
 3209                document.getElementById('bitrixSyncPriceIndexWrap')?.classList.add('d-none');
 3210            }, 2000);
 3211        });
 3212
 3213        await syncPriceIndexConnection.start();
 3214        await syncPriceIndexConnection.invoke('JoinJobGroup', jobId);
 3215    }
 3216
 3217    document.getElementById('bitrixSyncPricePushStartBtn')?.addEventListener('click', async () => {
 3218        if (!currentReportId) { St2Dialog.alert('Сначала откройте отчёт'); return; }
 3219        if (!(await St2Dialog.confirm('Запустить автоматический накат оптимальных цен по снимкам отчёта? Задание выполня
 3220        const onlyPending = document.getElementById('bitrixSyncPricePushOnlyPending')?.checked !== false;
 3221        await executePricePush(onlyPending);
 3222    });
 3223
 3224    document.getElementById('bitrixSyncApplyStartBtn')?.addEventListener('click', startSyncApply);
 3225
 3226    // Показать inline-результат в строке таблицы
 3227    function showRowApplyResult(btn, success, message) {
 3228        const td = btn.closest('td');
 3229        if (!td) return;
 3230
 3231        // Убираем предыдущий результат
 3232        td.querySelectorAll('.row-apply-result').forEach(el => el.remove());
 3233
 3234        const icon = success ? '✓' : '✗';
 3235        const cls  = success ? 'text-success' : 'text-danger';
 3236        const div  = document.createElement('div');
 3237        div.className = `row-apply-result small ${cls} mt-1`;
 3238        div.style.whiteSpace = 'normal';
 3239        div.style.maxWidth = '160px';
 3240        div.textContent = `${icon} ${message}`;
 3241        td.appendChild(div);
 3242    }
 3243
 3244    // Накат одного товара прямо из строки таблицы — с inline результатом
 3245    async function applySingleArticle(article, action, btn) {
 3246        if (!currentReportId || !article) return { success: false, message: 'Нет отчёта' };
 3247        const jobId = newSyncJobId();
 3248        const body = {
 3249            jobId,
 3250            reportId: currentReportId,
 3251            actions: [action],
 3252            articles: [article],
 3253            onlyPending: false,
 3254            disabledWarehouseMappingIds: null,
 3255            archivedMode: 0
 3256        };
 3257
 3258        return new Promise(async (resolve) => {
 3259            let completed = false;
 3260            const logLines = [];
 3261
 3262            try {
 3263                await connectApplySignalR(jobId);
 3264
 3265                const logHandler = msg => { if (msg) logLines.push(msg); };
 3266                syncApplyConnection.on('Log', logHandler);
 3267
 3268                const handler = async (success, payload) => {
 3269                    if (completed) return;
 3270                    completed = true;
 3271                    syncApplyConnection.off('Complete', handler);
 3272                    syncApplyConnection.off('Log', logHandler);
 3273
 3274                    const ok = success === true || success === 'true';
 3275                    let msg;
 3276                    if (!ok) {
 3277                        msg = typeof payload === 'string' ? payload : 'Ошибка выполнения';
 3278                    } else {
 3279                        // Ищем значимую строку результата в логе
 3280                        const resultLine = [...logLines].reverse().find(l =>
 3281                            l.includes('✓') || l.includes('✗') ||
 3282                            l.includes('совпадает') || l.includes('Совпадает') ||
 3283                            l.includes('пропущено') || l.includes('Пропущено') ||
 3284                            l.includes('Ошибка') || l.includes('ошибка')
 3285                        );
 3286                        if (resultLine) {
 3287                            msg = resultLine.replace(/^[•\s→]+/, '').trim();
 3288                        } else {
 3289                            msg = logLines.length > 0 ? logLines[logLines.length - 1] : 'Применено';
 3290                        }
 3291                        // Перезагружаем таблицу чтобы показать новый статус
 3292                        if (currentReportId) {
 3293                            try {
 3294                                lastSyncReport = await fetchReport(currentReportId);
 3295                                renderSummary(lastSyncReport);
 3296                                renderTable(lastSyncReport);
 3297                            } catch (_) {}
 3298                        }
 3299                    }
 3300                    resolve({ success: ok, message: msg });
 3301                };
 3302
 3303                syncApplyConnection.on('Complete', handler);
 3304
 3305                const res = await fetch(syncReportApplyStartUrl(currentReportId), {
 3306                    method: 'POST',
 3307                    headers: { 'Content-Type': 'application/json' },
 3308                    body: JSON.stringify(body)
 3309                });
 3310                const data = await readJsonResponse(res);
 3311                if (!res.ok || !data.success) {
 3312                    completed = true;
 3313                    syncApplyConnection.off('Complete', handler);
 3314                    resolve({ success: false, message: data.error || 'Ошибка запуска задания' });
 3315                }
 3316            } catch (e) {
 3317                if (!completed) resolve({ success: false, message: e.message || 'Ошибка' });
 3318            }
 3319        });
 3320    }
 3321
 3322    document.getElementById('bitrixSyncReportView')?.addEventListener('click', async e => {
 3323        const btn = e.target.closest('.sync-row-apply-btn');
 3324        if (!btn) return;
 3325        const article = btn.dataset.article;
 3326        const action  = btn.dataset.action;
 3327        if (!article || !action) return;
 3328
 3329        btn.disabled = true;
 3330        const origHtml = btn.innerHTML;
 3331        btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
 3332
 3333        // Убираем предыдущий результат
 3334        btn.closest('td')?.querySelectorAll('.row-apply-result').forEach(el => el.remove());
 3335
 3336        const { success, message } = await applySingleArticle(article, action, btn);
 3337
 3338        btn.disabled = false;
 3339        btn.innerHTML = origHtml;
 3340        showRowApplyResult(btn, success, message);
 3341    });
 3342
 3343    // После подтверждения отключённых связей — проверяем архивные
 3344    document.getElementById('syncApplyWarningConfirmBtn')?.addEventListener('click', async () => {
 3345        const modal = bootstrap.Modal.getInstance(document.getElementById('syncApplyWarningModal'));
 3346        if (modal) modal.hide();
 3347        if (!window.pendingSyncApplyData) return;
 3348        const { actions, maxLines } = window.pendingSyncApplyData;
 3349        window.pendingSyncApplyData.disabledMappings = window.pendingDisabledMappings || [];
 3350        const _disabledCount = (window.pendingDisabledMappings || []).length;
 3351        appendApplyLog(`⚠️ ${_disabledCount} отключ. связей: остатки этих складов будут обнулены на Ozon перед синхрониз
 3352        delete window.pendingDisabledMappings;
 3353
 3354        // Если есть архивные — спросить
 3355        const archivedCount2 = lastSyncReport?.archivedCount || 0;
 3356        if (archivedCount2 > 0) {
 3357            document.getElementById('syncApplyArchiveCount').textContent = archivedCount2;
 3358            const autoCount2 = lastSyncReport?.autoArchivedCount || 0;
 3359            const manualCount2 = archivedCount2 - autoCount2;
 3360            const autoNote2 = document.getElementById('syncApplyArchiveAutoNote');
 3361            if (autoNote2) {
 3362                if (autoCount2 > 0 || manualCount2 > 0) {
 3363                    const autoSpan2 = document.getElementById('syncApplyArchiveAutoCount');
 3364                    const manualSpan2 = document.getElementById('syncApplyArchiveManualCount');
 3365                    if (autoSpan2) autoSpan2.textContent = autoCount2;
 3366                    if (manualSpan2) manualSpan2.textContent = manualCount2;
 3367                    autoNote2.classList.remove('d-none');
 3368                } else autoNote2.classList.add('d-none');
 3369            }
 3370            const filterAllRadio2 = document.getElementById('syncArchiveFilterAll');
 3371            if (filterAllRadio2) filterAllRadio2.checked = true;
 3372            loadAndShowArchivePreview();
 3373            new bootstrap.Modal(document.getElementById('syncApplyArchiveModal')).show();
 3374            return;
 3375        }
 3376
 3377        await executeSyncApply(actions, maxLines, window.pendingSyncApplyData.disabledMappings || [], 0);
 3378        delete window.pendingSyncApplyData;
 3379    });
 3380
 3381    // Архивная модаль — три варианта ответа
 3382    function bindArchiveModalBtn(id, mode) {
 3383        document.getElementById(id)?.addEventListener('click', async () => {
 3384            const modal = bootstrap.Modal.getInstance(document.getElementById('syncApplyArchiveModal'));
 3385            if (modal) modal.hide();
 3386            if (!window.pendingSyncApplyData) return;
 3387            const { actions, maxLines } = window.pendingSyncApplyData;
 3388            const disabled = window.pendingSyncApplyData.disabledMappings || [];
 3389            // Читаем выбранный фильтр типа архива только для режима UnarchiveFirst (mode=2)
 3390            const unarchiveFilter = mode === 2
 3391                ? Number(document.querySelector('input[name="syncArchiveFilter"]:checked')?.value ?? 0)
 3392                : 0;
 3393            delete window.pendingSyncApplyData;
 3394            await executeSyncApply(actions, maxLines, disabled, mode, unarchiveFilter);
 3395        });
 3396    }
 3397    bindArchiveModalBtn('syncApplyArchiveUnarchiveBtn', 2);   // UnarchiveFirst
 3398    bindArchiveModalBtn('syncApplyArchiveSkipBtn',     1);   // Skip
 3399    bindArchiveModalBtn('syncApplyArchiveIncludeBtn',  0);   // Include
 3400    document.getElementById('bitrixSyncReportStartBtn')?.addEventListener('click', startSyncReport);
 3401    document.getElementById('bitrixSyncOpenDisabledMappingsModalBtn')?.addEventListener('click', openDisabledMappingsMod
 3402    document.getElementById('bitrixSyncModalDisabledMappingsApplyBtn')?.addEventListener('click', applyDisabledMappingsA
 3403
 3404    document.getElementById('bitrixSyncReportSendEmailBtn')?.addEventListener('click', async () => {
 3405        if (!currentReportId || !window.St2ChannelTask) return;
 3406        const btn = document.getElementById('bitrixSyncReportSendEmailBtn');
 3407        const orig = btn.innerHTML;
 3408        const params = buildFilterParams();
 3409        const startUrl = `${syncReportSendEmailUrl(currentReportId)}?${params.toString()}`;
 3410        const doneStep = document.querySelector('#bitrixSyncExcelExportModal [data-step="Done"] span');
 3411        const prevDoneText = doneStep?.textContent;
 3412        if (doneStep) doneStep.textContent = 'Письмо отправлено';
 3413        try {
 3414            await St2ChannelTask.run({
 3415                startUrl,
 3416                startMethod: 'POST',
 3417                statusUrl: taskId => St2ChannelTask.channelStatusUrl(taskId),
 3418                downloadUrl: () => '#',
 3419                modalId: 'bitrixSyncExcelExportModal',
 3420                triggerBtn: btn,
 3421                loadingHtml: '<span class="spinner-border spinner-border-sm me-1"></span>Отправка…',
 3422                resultKind: 'Email',
 3423                onCompleted: () => {
 3424                    btn.classList.remove('btn-outline-info');
 3425                    btn.classList.add('btn-success');
 3426                    btn.innerHTML = '<i class="fas fa-check me-1"></i>Отправлено на lex@interparts.ru';
 3427                    setTimeout(() => {
 3428                        btn.classList.remove('btn-success');
 3429                        btn.classList.add('btn-outline-info');
 3430                        btn.innerHTML = orig;
 3431                    }, 4000);
 3432                }
 3433            });
 3434        } catch (_) {
 3435            btn.innerHTML = orig;
 3436        } finally {
 3437            if (doneStep && prevDoneText) doneStep.textContent = prevDoneText;
 3438        }
 3439    });
 3440
 3441    loadRunsList();
 3442    loadPresets();
 3443
 3444    // ── Пресеты ────────────────────────────────────────────────────────────
 3445
 3446    function presetUpdateUrl(id) { return syncPresetsBaseUrl + '/' + id; }
 3447    function presetDeleteUrl(id) { return syncPresetsBaseUrl + '/' + id; }
 3448
 3449    async function loadPresets() {
 3450        const container = document.getElementById('bitrixSyncPresetsContainer');
 3451        if (!container) return;
 3452        try {
 3453            const res = await fetch(syncPresetsUrl, { headers: { 'Accept': 'application/json' }, credentials: 'same-orig
 3454            const data = await res.json();
 3455            renderPresets(data.presets || []);
 3456        } catch (e) {
 3457            container.innerHTML = `<span class="small text-danger">${escapeHtml(e.message)}</span>`;
 3458        }
 3459    }
 3460
 3461    function renderPresets(presets) {
 3462        const container = document.getElementById('bitrixSyncPresetsContainer');
 3463        if (!container) return;
 3464        if (!presets.length) {
 3465            container.innerHTML = '<span class="small text-muted">Нет сохранённых пресетов. Настройте параметры и нажмит
 3466            return;
 3467        }
 3468        container.innerHTML = `<div class="d-flex flex-wrap gap-2">${presets.map(p => renderPresetCard(p)).join('')}</di
 3469    }
 3470
 3471    function renderPresetCard(p) {
 3472        const stores = (p.selectedStores || p.SelectedStores || []).join(', ') || '—';
 3473        const article = p.articleFilter || p.ArticleFilter || '';
 3474        const cid = p.ozonClientId || p.OzonClientId || 0;
 3475        const cname = p.ozonClientName || p.OzonClientName || `ID ${cid}`;
 3476        return `<div class="border rounded p-2 bg-light position-relative" style="min-width:200px;max-width:260px" data-
 3477          <div class="fw-semibold small text-truncate mb-1">${escapeHtml(p.name || p.Name)}</div>
 3478          <div class="small text-muted text-truncate"><i class="fas fa-user-circle me-1"></i>${escapeHtml((() => {
 3479              const savedClients = p.ozonClients || p.OzonClients || [];
 3480              if (savedClients.length > 1) return savedClients.map((c,i) => `${i+1}. ${c.name||c.Name}`).join(', ');
 3481              return cname;
 3482          })())}</div>
 3483          <div class="small text-muted text-truncate"><i class="fas fa-warehouse me-1"></i>${escapeHtml(stores)}</div>
 3484          ${article ? `<div class="small text-muted text-truncate"><i class="fas fa-barcode me-1"></i>${escapeHtml(artic
 3485          <div class="d-flex gap-1 mt-2">
 3486            <button class="btn btn-xs btn-primary py-0 px-2 small preset-run-btn" data-preset-id="${p.id || p.Id}">
 3487              <i class="fas fa-play me-1"></i>Запустить
 3488            </button>
 3489            <button class="btn btn-xs btn-outline-secondary py-0 px-2 small preset-edit-btn" data-preset-id="${p.id || p
 3490              <i class="fas fa-pen"></i>
 3491            </button>
 3492            <button class="btn btn-xs btn-outline-warning py-0 px-2 small preset-exceptions-btn" data-preset-id="${p.id 
 3493              <i class="fas fa-ban"></i>
 3494            </button>
 3495            <button class="btn btn-xs btn-outline-danger py-0 px-2 small preset-delete-btn" data-preset-id="${p.id || p.
 3496              <i class="fas fa-trash"></i>
 3497            </button>
 3498          </div>
 3499        </div>`;
 3500    }
 3501
 3502    // Клик по кнопкам пресетов (делегирование)
 3503    document.getElementById('bitrixSyncPresetsContainer')?.addEventListener('click', async e => {
 3504        const runBtn    = e.target.closest('.preset-run-btn');
 3505        const editBtn   = e.target.closest('.preset-edit-btn');
 3506        const excBtn    = e.target.closest('.preset-exceptions-btn');
 3507        const deleteBtn = e.target.closest('.preset-delete-btn');
 3508
 3509        if (runBtn)    { await runPreset(Number(runBtn.getAttribute('data-preset-id'))); return; }
 3510        if (editBtn)   { await openPresetModal(Number(editBtn.getAttribute('data-preset-id'))); return; }
 3511        if (excBtn) {
 3512            const card = excBtn.closest('[data-preset-id]');
 3513            const cid  = Number(card?.dataset.clientId || 0);
 3514            const cname = card?.dataset.clientName || `ID ${cid}`;
 3515            if (typeof window.openExceptionsModal === 'function') window.openExceptionsModal(cid, cname);
 3516            return;
 3517        }
 3518        if (deleteBtn) { await deletePreset(Number(deleteBtn.getAttribute('data-preset-id'))); return; }
 3519    });
 3520
 3521    async function runPreset(id) {
 3522        try {
 3523            const res = await fetch(syncPresetsUrl, { headers: { 'Accept': 'application/json' }, credentials: 'same-orig
 3524            const data = await res.json();
 3525            const preset = (data.presets || []).find(p => (p.id || p.Id) === id);
 3526            if (!preset) { St2Dialog.alert('Пресет не найден'); return; }
 3527
 3528            // Заполняем форму — мультиклиентный список из пресета
 3529            syncClientOrder.length = 0;
 3530            const savedClients = preset.ozonClients || preset.OzonClients || [];
 3531            if (savedClients.length) {
 3532                for (const c of savedClients)
 3533                    syncClientOrder.push({ id: c.id || c.Id, name: c.name || c.Name || '' });
 3534            } else {
 3535                const cid = preset.ozonClientId || preset.OzonClientId || 0;
 3536                const cname = preset.ozonClientName || preset.OzonClientName || `ID ${cid}`;
 3537                if (cid) syncClientOrder.push({ id: cid, name: cname });
 3538            }
 3539            renderClientList();
 3540            saveClientOrder();
 3541
 3542            const articleInp = document.getElementById('bitrixSyncStartArticle');
 3543            if (articleInp) articleInp.value = preset.articleFilter || preset.ArticleFilter || '';
 3544
 3545            const stores = preset.selectedStores || preset.SelectedStores || [];
 3546            document.querySelectorAll('.bitrix-sync-start-store-cb').forEach(cb => {
 3547                cb.checked = stores.some(s => s.toLowerCase() === cb.value.toLowerCase());
 3548            });
 3549
 3550            // Восстанавливаем отключенные связи из пресета
 3551            const disabledIds = preset.disabledWarehouseMappingIds || preset.DisabledWarehouseMappingIds || [];
 3552            if (disabledIds.length > 0) {
 3553                // Сохраняем в формате массива для передачи при создании отчета
 3554                localStorage.setItem('bitrixSyncTempDisabledMappings', JSON.stringify(disabledIds));
 3555            }
 3556
 3557            // Передаём настройки этапов в POST sync-reports/start — всю цепочку 1→5 ведёт
 3558            // серверный оркестратор (BitrixStockSyncPipelineOrchestrator). Клиент только отображает прогресс.
 3559            const stageSettings = preset.stageSettings || preset.StageSettings || null;
 3560            window._presetStageSettingsForStart = stageSettings;
 3561            window._lastPipelineConfigId = _pipelineConfigId(stageSettings);
 3562
 3563            const presetName = preset.name || preset.Name || 'Пресет';
 3564            const title = document.getElementById('presetAutoRunTitle');
 3565            if (title) title.textContent = presetName;
 3566
 3567            // Панель прогресса показывается в startSyncReport после выделения jobId/reportId
 3568            document.getElementById('bitrixSyncReportStartBtn')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
 3569            await loadRunsList();
 3570            document.getElementById('bitrixSyncReportStartBtn')?.click();
 3571        } catch (e) { St2Dialog.alert('Ошибка: ' + e.message); }
 3572    }
 3573
 3574    async function deletePreset(id) {
 3575        if (!(await St2Dialog.confirm('Удалить пресет?', { variant: 'danger', confirmText: 'Удалить' }))) return;
 3576        await fetch(presetDeleteUrl(id), { method: 'DELETE', credentials: 'same-origin' });
 3577        await loadPresets();
 3578    }
 3579
 3580    async function loadPresetDisabledMappings(clientIds) {
 3581        const container = document.getElementById('syncPresetDisabledMappingsContainer');
 3582        const section = document.getElementById('syncPresetDisabledMappingsSection');
 3583
 3584        if (!container || !section || !clientIds || !clientIds.length) {
 3585            if (section) section.style.display = 'none';
 3586            return;
 3587        }
 3588
 3589        try {
 3590            const allMappings = await loadMappingsForClients(clientIds.map(Number));
 3591            if (!allMappings.length) {
 3592                section.style.display = 'none';
 3593                container.innerHTML = '<span class="small text-danger">Ошибка загрузки связей</span>';
 3594                return;
 3595            }
 3596
 3597            const mappings = allMappings;
 3598            if (mappings.length === 0) {
 3599                section.style.display = 'none';
 3600                container.innerHTML = '<span class="text-muted">Нет доступных связей</span>';
 3601                return;
 3602            }
 3603
 3604            // Загрузить сохраненное состояние (из data атрибута или пусто)
 3605            const savedAttr = document.getElementById('syncPresetEditId').getAttribute('data-disabled-mappings');
 3606            let disabledIds = [];
 3607            if (savedAttr) {
 3608                try {
 3609                    disabledIds = JSON.parse(savedAttr);
 3610                } catch (_) { }
 3611            }
 3612
 3613            let html = '';
 3614            mappings.forEach(m => {
 3615                // Логика: галка = активна, без галки = отключена
 3616                // isDisabled = связь отключена (есть в списке отключенных)
 3617                const isDisabled = disabledIds.includes(m.id);
 3618                // checked = !isDisabled (галка стоит если связь НЕ отключена)
 3619                html += `
 3620                <div class="form-check mb-1">
 3621                    <input class="form-check-input sync-preset-disabled-mapping-cb" type="checkbox"
 3622                           value="${m.id}" id="syncPresetDisabledMapping_${m.id}" ${!isDisabled ? 'checked' : ''}/>
 3623                    <label class="form-check-label small" for="syncPresetDisabledMapping_${m.id}" title="${escapeHtml(m.
 3624                        ${escapeHtml(m.description)} <span class="text-primary">(${escapeHtml(m._clientName || '')})</sp
 3625                    </label>
 3626                </div>`;
 3627            });
 3628            container.innerHTML = html;
 3629            // Показываем раздел только после успешной загрузки
 3630            section.style.display = 'block';
 3631        } catch (e) {
 3632            console.error('Ошибка загрузки связей:', e);
 3633            section.style.display = 'none';
 3634            container.innerHTML = '<span class="small text-danger">Ошибка: ' + e.message + '</span>';
 3635        }
 3636    }
 3637
 3638    async function loadPresetApplyMappings(clientIds) {
 3639        const container = document.getElementById('syncPresetApplyMappingsContainer');
 3640        const section   = document.getElementById('syncPresetApplyMappingsSection');
 3641
 3642        if (!container || !section || !clientIds || !clientIds.length) {
 3643            if (section) section.style.display = 'none';
 3644            return;
 3645        }
 3646
 3647        try {
 3648            const mappings = await loadMappingsForClients(clientIds.map(Number));
 3649            if (!mappings.length) {
 3650                section.style.display = 'none';
 3651                return;
 3652            }
 3653
 3654            // Читаем сохранённые отключённые ID из data-атрибута
 3655            const savedAttr = document.getElementById('syncPresetEditId').getAttribute('data-apply-disabled-mappings');
 3656            let disabledIds = [];
 3657            try { if (savedAttr) disabledIds = JSON.parse(savedAttr); } catch (_) {}
 3658
 3659            let html = '<div class="row g-2">';
 3660            mappings.forEach(m => {
 3661                const isDisabled = disabledIds.includes(m.id);
 3662                html += `
 3663                <div class="col-md-6">
 3664                    <div class="border rounded p-2 bg-white">
 3665                        <div class="form-check">
 3666                            <input class="form-check-input sync-preset-apply-mapping-cb" type="checkbox"
 3667                                   value="${m.id}" id="syncPresetApplyMapping_${m.id}" ${!isDisabled ? 'checked' : ''}/>
 3668                            <label class="form-check-label" for="syncPresetApplyMapping_${m.id}">
 3669                                <div class="small fw-semibold">${escapeHtml(m.bitrixWarehouse)}</div>
 3670                                <div class="small text-muted"><i class="fas fa-arrow-right me-1"></i>${escapeHtml(m.ozon
 3671                                <div class="small text-primary"><i class="fas fa-user me-1"></i>${escapeHtml(m._clientNa
 3672                            </label>
 3673                        </div>
 3674                    </div>
 3675                </div>`;
 3676            });
 3677            html += '</div>';
 3678            container.innerHTML = html;
 3679            section.style.display = 'block';
 3680        } catch (e) {
 3681            console.error('Ошибка загрузки связей этапа 2:', e);
 3682            section.style.display = 'none';
 3683        }
 3684    }
 3685
 3686    async function loadPresetOrphanMappings(clientIds) {
 3687        const container = document.getElementById('syncPresetOrphanMappingsContainer');
 3688        const section   = document.getElementById('syncPresetOrphanMappingsSection');
 3689
 3690        if (!container || !section || !clientIds || !clientIds.length) {
 3691            if (section) section.style.display = 'none';
 3692            return;
 3693        }
 3694
 3695        try {
 3696            const mappings = await loadMappingsForClients(clientIds.map(Number));
 3697            if (!mappings.length) {
 3698                section.style.display = 'none';
 3699                return;
 3700            }
 3701
 3702            // Читаем сохранённые исключённые ID из data-атрибута (снятые галки)
 3703            const savedAttr = document.getElementById('syncPresetEditId').getAttribute('data-orphan-disabled-mappings');
 3704            let disabledIds = [];
 3705            try { if (savedAttr) disabledIds = JSON.parse(savedAttr); } catch (_) {}
 3706
 3707            let html = '<div class="row g-2">';
 3708            mappings.forEach(m => {
 3709                const isDisabled = disabledIds.includes(m.id);
 3710                html += `
 3711                <div class="col-md-6">
 3712                    <div class="border rounded p-2 bg-white">
 3713                        <div class="form-check">
 3714                            <input class="form-check-input sync-preset-orphan-mapping-cb" type="checkbox"
 3715                                   value="${m.id}" id="syncPresetOrphanMapping_${m.id}" ${!isDisabled ? 'checked' : ''}/
 3716                            <label class="form-check-label" for="syncPresetOrphanMapping_${m.id}">
 3717                                <div class="small fw-semibold">${escapeHtml(m.bitrixWarehouse)} <span class="text-second
 3718                                <div class="small text-muted"><i class="fas fa-arrow-right me-1"></i>${escapeHtml(m.ozon
 3719                                <div class="small text-primary"><i class="fas fa-user me-1"></i>${escapeHtml(m._clientNa
 3720                            </label>
 3721                        </div>
 3722                    </div>
 3723                </div>`;
 3724            });
 3725            html += '</div>';
 3726            container.innerHTML = html;
 3727            section.style.display = 'block';
 3728        } catch (e) {
 3729            console.error('Ошибка загрузки связей этапа 5:', e);
 3730            section.style.display = 'none';
 3731        }
 3732    }
 3733
 3734    function applyPresetStageSettingsToModal(s) {
 3735        const reserveStatusIds = (s?.reserveStatusIds ?? []).map(Number);
 3736        const reserveCbs = [...document.querySelectorAll('.sync-preset-reserve-status-cb')];
 3737        if (reserveCbs.length) {
 3738            if (reserveStatusIds.length > 0) {
 3739                reserveCbs.forEach(cb => { cb.checked = reserveStatusIds.includes(Number(cb.value)); });
 3740            } else {
 3741                reserveCbs.forEach(cb => { cb.checked = cb.defaultChecked; });
 3742            }
 3743        }
 3744
 3745        // Stage 2
 3746        const autoApply = s?.autoRunApply ?? false;
 3747        const applyEl = document.getElementById('syncPresetAutoRunApply');
 3748        if (applyEl) applyEl.checked = autoApply;
 3749
 3750        const actions = s?.applyActions ?? ['Push', 'Decrease', 'Clear'];
 3751        document.querySelectorAll('.sync-preset-apply-action-cb').forEach(cb => {
 3752            cb.checked = actions.includes(cb.value);
 3753        });
 3754
 3755        const archivedMode = String(s?.applyArchivedMode ?? 0);
 3756        document.querySelectorAll('input[name="syncPresetArchivedMode"]').forEach(r => {
 3757            r.checked = r.value === archivedMode;
 3758        });
 3759        const unarchiveFilter = String(s?.applyUnarchiveFilter ?? 0);
 3760        document.querySelectorAll('input[name="syncPresetUnarchiveFilter"]').forEach(r => {
 3761            r.checked = r.value === unarchiveFilter;
 3762        });
 3763        const maxLinesEl = document.getElementById('syncPresetApplyMaxLines');
 3764        if (maxLinesEl) maxLinesEl.value = s?.applyMaxLines != null ? String(s.applyMaxLines) : '';
 3765        _updatePresetUnarchiveFilterVisibility();
 3766
 3767        // Сохраняем applyDisabledWarehouseMappingIds в data-атрибут (loadPresetApplyMappings его прочитает)
 3768        const applyDisabledIds = s?.applyDisabledWarehouseMappingIds ?? [];
 3769        document.getElementById('syncPresetEditId')?.setAttribute('data-apply-disabled-mappings', JSON.stringify(applyDi
 3770
 3771        // Stage 3
 3772        const autoCleanup = s?.autoRunCleanup ?? false;
 3773        const cleanupEl = document.getElementById('syncPresetAutoRunCleanup');
 3774        if (cleanupEl) cleanupEl.checked = autoCleanup;
 3775        const onlyPendingEl = document.getElementById('syncPresetCleanupOnlyPending');
 3776        if (onlyPendingEl) onlyPendingEl.checked = s?.cleanupOnlyPending !== false;
 3777
 3778        // Stage 4 — auto price push + percentages
 3779        const autoPricePushEl = document.getElementById('syncPresetAutoRunPricePush');
 3780        if (autoPricePushEl) autoPricePushEl.checked = s?.autoRunPricePush ?? false;
 3781
 3782        // Stage 5 — ozon orphan cleanup
 3783        const autoOrphanEl = document.getElementById('syncPresetAutoRunOzonOrphanCleanup');
 3784        if (autoOrphanEl) autoOrphanEl.checked = s?.autoRunOzonOrphanCleanup ?? false;
 3785        // Исключённые связи складов этапа 5 → data-атрибут (loadPresetOrphanMappings его прочитает)
 3786        const orphanDisabledIds = s?.orphanDisabledWarehouseMappingIds ?? [];
 3787        document.getElementById('syncPresetEditId')?.setAttribute('data-orphan-disabled-mappings', JSON.stringify(orphan
 3788        const stage5CatalogSource = s?.stage5CatalogSource ?? 'OzonProductsDb';
 3789        const stage5CatalogEl = document.getElementById('syncPresetStage5CatalogSource');
 3790        if (stage5CatalogEl) stage5CatalogEl.value = stage5CatalogSource;
 3791        const noPromoEl = document.getElementById('syncPresetNoPromoMarkup');
 3792        if (noPromoEl) noPromoEl.value = s?.noPromoMarkupPercent ?? 5;
 3793        const promoEl = document.getElementById('syncPresetPromoMarkup');
 3794        if (promoEl) promoEl.value = s?.promoMarkupPercent ?? 10;
 3795        _updatePresetMarkupHint('syncPresetNoPromoMarkup', 'syncPresetNoPromoMarkupHint');
 3796        _updatePresetMarkupHint('syncPresetPromoMarkup',   'syncPresetPromoMarkupHint');
 3797    }
 3798
 3799    function _updatePresetMarkupHint(inputId, hintId) {
 3800        const el = document.getElementById(hintId);
 3801        if (el) el.classList.toggle('d-none', parseFloat(document.getElementById(inputId)?.value) !== 0);
 3802    }
 3803
 3804    function _updatePresetUnarchiveFilterVisibility() {
 3805        const mode = document.querySelector('input[name="syncPresetArchivedMode"]:checked')?.value;
 3806        const sec = document.getElementById('syncPresetUnarchiveFilterSection');
 3807        if (sec) sec.classList.toggle('d-none', mode !== '2');
 3808    }
 3809
 3810    document.querySelectorAll('input[name="syncPresetArchivedMode"]').forEach(r => {
 3811        r.addEventListener('change', _updatePresetUnarchiveFilterVisibility);
 3812    });
 3813
 3814    ['syncPresetNoPromoMarkup', 'syncPresetPromoMarkup'].forEach(id => {
 3815        const hintId = id + 'Hint';
 3816        document.getElementById(id)?.addEventListener('input', () => _updatePresetMarkupHint(id, hintId));
 3817        document.getElementById(id)?.addEventListener('change', () => _updatePresetMarkupHint(id, hintId));
 3818    });
 3819
 3820    function openPresetModal(id) {
 3821        document.getElementById('syncPresetEditId').value = id ? String(id) : '';
 3822        const titleEl = document.getElementById('syncPresetModalTitle');
 3823
 3824        presetClientOrder.length = 0;
 3825
 3826        if (!id) {
 3827            // Создание — подставляем текущие настройки формы
 3828            if (titleEl) titleEl.textContent = 'Новый пресет';
 3829            const articleVal = document.getElementById('bitrixSyncStartArticle')?.value || '';
 3830            const checkedStores = [...document.querySelectorAll('.bitrix-sync-start-store-cb:checked')].map(cb => cb.val
 3831
 3832            document.getElementById('syncPresetName').value = '';
 3833            document.getElementById('syncPresetArticle').value = articleVal;
 3834            document.querySelectorAll('.sync-preset-store-cb').forEach(cb => {
 3835                cb.checked = checkedStores.some(s => s === cb.value);
 3836            });
 3837            applyPresetStageSettingsToModal(null); // устанавливаем data-атрибуты до renderPresetClientList
 3838            // Подставляем клиентов из текущей формы
 3839            for (const c of syncClientOrder) presetClientOrder.push({ ...c });
 3840            renderPresetClientList(); // вызывает loadPresetDisabledMappings + loadPresetApplyMappings
 3841            if (presetClientOrder.length) loadPresetDisabledMappings(getPresetClientIds().map(String));
 3842        } else {
 3843            if (titleEl) titleEl.textContent = 'Редактировать пресет';
 3844            fetch(syncPresetsUrl, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
 3845                .then(r => r.json()).then(data => {
 3846                    const p = (data.presets || []).find(x => (x.id || x.Id) === id);
 3847                    if (!p) return;
 3848                    document.getElementById('syncPresetName').value = p.name || p.Name || '';
 3849                    document.getElementById('syncPresetArticle').value = p.articleFilter || p.ArticleFilter || '';
 3850                    const stores = p.selectedStores || p.SelectedStores || [];
 3851                    document.querySelectorAll('.sync-preset-store-cb').forEach(cb => {
 3852                        cb.checked = stores.some(s => s === cb.value);
 3853                    });
 3854                    // Устанавливаем настройки этапов ДО renderPresetClientList,
 3855                    // чтобы data-атрибуты были готовы для loadPresetApplyMappings
 3856                    applyPresetStageSettingsToModal(p.stageSettings || p.StageSettings || null);
 3857                    // Восстанавливаем список клиентов
 3858                    presetClientOrder.length = 0;
 3859                    const savedClients = p.ozonClients || p.OzonClients || [];
 3860                    if (savedClients.length) {
 3861                        for (const c of savedClients) presetClientOrder.push({ id: c.id || c.Id, name: c.name || c.Name 
 3862                    } else {
 3863                        const cid = p.ozonClientId || p.OzonClientId || 0;
 3864                        const cname = p.ozonClientName || p.OzonClientName || `ID ${cid}`;
 3865                        if (cid) presetClientOrder.push({ id: cid, name: cname });
 3866                    }
 3867                    const disabledIds = p.disabledWarehouseMappingIds || p.DisabledWarehouseMappingIds || [];
 3868                    document.getElementById('syncPresetEditId').setAttribute('data-disabled-mappings', JSON.stringify(di
 3869                    renderPresetClientList(); // вызывает loadPresetDisabledMappings + loadPresetApplyMappings
 3870                });
 3871        }
 3872
 3873        const errEl = document.getElementById('syncPresetModalError');
 3874        if (errEl) errEl.classList.add('d-none');
 3875        new bootstrap.Modal(document.getElementById('syncPresetModal')).show();
 3876    }
 3877
 3878    document.getElementById('bitrixSyncPresetSaveBtn')?.addEventListener('click', () => openPresetModal(0));
 3879    document.getElementById('syncPresetClient')?.addEventListener('change', async (e) => {
 3880        const clientId = e.target.value;
 3881        if (clientId) {
 3882            await loadPresetDisabledMappings([clientId]);
 3883            await loadPresetApplyMappings([clientId]);
 3884            await loadPresetOrphanMappings([clientId]);
 3885        } else {
 3886            const container = document.getElementById('syncPresetDisabledMappingsContainer');
 3887            if (container) container.innerHTML = '<span class="text-muted">Выберите клиента выше</span>';
 3888        }
 3889    });
 3890    document.getElementById('syncPresetStoresAll')?.addEventListener('click', () => {
 3891        document.querySelectorAll('.sync-preset-store-cb').forEach(cb => { cb.checked = true; });
 3892    });
 3893    document.getElementById('syncPresetStoresNone')?.addEventListener('click', () => {
 3894        document.querySelectorAll('.sync-preset-store-cb').forEach(cb => { cb.checked = false; });
 3895    });
 3896
 3897    document.getElementById('syncPresetSaveConfirmBtn')?.addEventListener('click', async () => {
 3898        const id      = Number(document.getElementById('syncPresetEditId').value) || 0;
 3899        const name    = document.getElementById('syncPresetName').value.trim();
 3900        const article = document.getElementById('syncPresetArticle').value.trim() || null;
 3901        const stores  = [...document.querySelectorAll('.sync-preset-store-cb:checked')].map(cb => cb.value);
 3902        const clients = getPresetClients(); // [{id, name}, ...]
 3903
 3904        const disabledMappingIds = [...document.querySelectorAll('.sync-preset-disabled-mapping-cb')]
 3905            .filter(cb => !cb.checked)
 3906            .map(cb => Number(cb.value));
 3907
 3908        const errEl = document.getElementById('syncPresetModalError');
 3909        if (!name)           { errEl.textContent = 'Введите название'; errEl.classList.remove('d-none'); return; }
 3910        if (!clients.length) { errEl.textContent = 'Добавьте хотя бы одного клиента'; errEl.classList.remove('d-none'); 
 3911        if (!stores.length)  { errEl.textContent = 'Выберите склады'; errEl.classList.remove('d-none'); return; }
 3912        errEl.classList.add('d-none');
 3913
 3914        const applyActions = [...document.querySelectorAll('.sync-preset-apply-action-cb:checked')].map(cb => cb.value);
 3915        const applyArchivedMode = Number(document.querySelector('input[name="syncPresetArchivedMode"]:checked')?.value ?
 3916        const applyUnarchiveFilter = Number(document.querySelector('input[name="syncPresetUnarchiveFilter"]:checked')?.v
 3917        const applyMaxLinesRaw = document.getElementById('syncPresetApplyMaxLines')?.value?.trim();
 3918        const applyMaxLines = applyMaxLinesRaw ? Number(applyMaxLinesRaw) : null;
 3919        const applyDisabledMappingIds = [...document.querySelectorAll('.sync-preset-apply-mapping-cb')]
 3920            .filter(cb => !cb.checked)
 3921            .map(cb => Number(cb.value));
 3922        const orphanDisabledMappingIds = [...document.querySelectorAll('.sync-preset-orphan-mapping-cb')]
 3923            .filter(cb => !cb.checked)
 3924            .map(cb => Number(cb.value));
 3925        const reserveStatusIds = [...document.querySelectorAll('.sync-preset-reserve-status-cb:checked')]
 3926            .map(cb => Number(cb.value))
 3927            .filter(v => Number.isInteger(v) && v > 0);
 3928
 3929        const body = {
 3930            name,
 3931            ozonClients: clients.map(c => ({ id: c.id, name: c.name })),
 3932            ozonClientId:   clients[0].id,
 3933            ozonClientName: clients[0].name,
 3934            articleFilter: article,
 3935            selectedStores: stores,
 3936            disabledWarehouseMappingIds: disabledMappingIds.length > 0 ? disabledMappingIds : [],
 3937            stageSettings: {
 3938                reserveStatusIds,
 3939                autoRunApply:                  document.getElementById('syncPresetAutoRunApply')?.checked ?? false,
 3940                applyActions:                  applyActions.length ? applyActions : ['Push', 'Decrease', 'Clear'],
 3941                applyArchivedMode,
 3942                applyUnarchiveFilter,
 3943                applyMaxLines,
 3944                applyDisabledWarehouseMappingIds: applyDisabledMappingIds,
 3945                autoRunCleanup:                document.getElementById('syncPresetAutoRunCleanup')?.checked ?? false,
 3946                cleanupOnlyPending:            document.getElementById('syncPresetCleanupOnlyPending')?.checked !== fals
 3947                autoRunPricePush:              document.getElementById('syncPresetAutoRunPricePush')?.checked ?? false,
 3948                noPromoMarkupPercent:          (v => isNaN(v) ? 5 : v)(parseFloat(document.getElementById('syncPresetNoP
 3949                promoMarkupPercent:            (v => isNaN(v) ? 10 : v)(parseFloat(document.getElementById('syncPresetPr
 3950                autoRunOzonOrphanCleanup:      document.getElementById('syncPresetAutoRunOzonOrphanCleanup')?.checked ??
 3951                stage5CatalogSource:           document.getElementById('syncPresetStage5CatalogSource')?.value || 'OzonP
 3952                orphanDisabledWarehouseMappingIds: orphanDisabledMappingIds
 3953            }
 3954        };
 3955        const saveBtn = document.getElementById('syncPresetSaveConfirmBtn');
 3956        saveBtn.disabled = true;
 3957        try {
 3958            const res = await fetch(
 3959                id ? presetUpdateUrl(id) : syncPresetsCreateUrl,
 3960                { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origi
 3961            );
 3962            const data = await res.json();
 3963            if (!data.success) { errEl.textContent = data.error || 'Ошибка сохранения'; errEl.classList.remove('d-none')
 3964            bootstrap.Modal.getInstance(document.getElementById('syncPresetModal'))?.hide();
 3965            await loadPresets();
 3966        } catch (e) {
 3967            errEl.textContent = e.message; errEl.classList.remove('d-none');
 3968        } finally { saveBtn.disabled = false; }
 3969    });
 3970
 3971    // ── Excel overlay ──────────────────────────────────────────────────────
 3972    function showExcelOverlay() {
 3973        let ov = document.getElementById('syncExcelOverlay');
 3974        if (!ov) {
 3975            ov = document.createElement('div');
 3976            ov.id = 'syncExcelOverlay';
 3977            ov.innerHTML = `
 3978              <div style="
 3979                position:fixed;inset:0;z-index:9999;
 3980                background:rgba(0,0,0,.45);
 3981                display:flex;flex-direction:column;
 3982                align-items:center;justify-content:center;gap:1.25rem;">
 3983                <div style="
 3984                  background:#fff;border-radius:0.75rem;
 3985                  padding:2rem 2.5rem;
 3986                  box-shadow:0 8px 32px rgba(0,0,0,.25);
 3987                  text-align:center;min-width:280px;">
 3988                  <div class="mb-3">
 3989                    <svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
 3990                      <rect width="56" height="56" rx="12" fill="#1D6F42"/>
 3991                      <path d="M14 18h28v4H14zM14 26h20v4H14zM14 34h24v4H14z" fill="#fff" opacity=".3"/>
 3992                      <path d="M20 14l4 8-4 8h4l2-4 2 4h4l-4-8 4-8h-4l-2 4-2-4z" fill="#fff"/>
 3993                    </svg>
 3994                  </div>
 3995                  <div class="fw-semibold mb-1" style="font-size:1.05rem">Формирование Excel…</div>
 3996                  <div class="text-muted small mb-3">Загружаются все строки отчёта</div>
 3997                  <div class="progress" style="height:6px;border-radius:3px">
 3998                    <div id="syncExcelProgress"
 3999                         class="progress-bar progress-bar-striped progress-bar-animated bg-success"
 4000                         style="width:100%"></div>
 4001                  </div>
 4002                </div>
 4003              </div>`;
 4004            document.body.appendChild(ov);
 4005        }
 4006        ov.style.display = 'block';
 4007    }
 4008
 4009    function hideExcelOverlay() {
 4010        const ov = document.getElementById('syncExcelOverlay');
 4011        if (ov) ov.style.display = 'none';
 4012    }
 4013
 4014    // ─── Ozon price snapshot modal ────────────────────────────────────────────
 4015    function syncColorIndexDesc(v) {
 4016        if (!v || v === 'WITHOUT_INDEX') return '<span style="color:gray;font-weight:bold" title="Нет индекса">Нет индек
 4017        if (v === 'SUPER')               return '<span style="color:#7c3aed;font-weight:bold" title="Супервыгодный">Супе
 4018        if (v === 'BENEFIT' || v === 'GREEN')    return '<span style="color:green;font-weight:bold"  title="Выгодный">Вы
 4019        if (v === 'AVERAGE' || v === 'YELLOW')   return '<span style="color:orange;font-weight:bold" title="Умеренный">У
 4020        if (v === 'OVERPRICED' || v === 'RED')   return '<span style="color:red;font-weight:bold"    title="Невыгодный">
 4021        return escapeHtml(v);
 4022    }
 4023    function syncFmtVal(v) {
 4024        if (v == null || v === '' || v === '-') return '-';
 4025        const n = Number(v);
 4026        return isNaN(n) ? escapeHtml(String(v)) : Math.round(n).toLocaleString('ru-RU');
 4027    }
 4028
 4029    // Рендерит секцию оптимизации по готовому результату с сервера (GetPriceOptimization).
 4030    // opt — первый элемент массива ответа; raw values хранятся в data-атрибутах для live-пересчёта.
 4031    // ── Вспомогательные функции секции оптимизации цены ────────────────────
 4032    function _priceOptFmt(v, dec) {
 4033        if (v == null) return '—';
 4034        const n = Number(v);
 4035        return isNaN(n) ? '—' : n.toLocaleString('ru-RU', { maximumFractionDigits: dec ?? 0, minimumFractionDigits: 0 })
 4036    }
 4037    const _priceOptFmtRub  = v => v != null ? _priceOptFmt(v) + ' ₽' : '—';
 4038    const _priceOptFmtPct1 = v => v != null ? Number(v).toFixed(1) + '%' : '—';
 4039    const _priceOptFmtPct2 = v => v != null ? Number(v).toFixed(2) + '%' : '—';
 4040
 4041    // Проверяет winner против текущей цены truePrice и корректирует вниз до валидной.
 4042    // Возвращает { price, corrected, originalPrice, reason }.
 4043    function _validateAndAdjustWinner(winner, truePrice) {
 4044        if (winner == null) return { price: null, corrected: false };
 4045        const w  = Math.round(winner);
 4046        const tp = truePrice != null ? Math.round(truePrice) : null;
 4047        // Правила применимы только при снижении цены (new < old)
 4048        if (tp == null || w >= tp) return { price: w, corrected: false };
 4049
 4050        let current = w;
 4051        let reason  = null;
 4052        for (let i = 0; i < 3; i++) {
 4053            if (current <= 0) { current = 1; break; }
 4054            const diff = tp - current;
 4055            if (current <= 400) {
 4056                if (diff > 20) break;
 4057                current = Math.max(1, tp - 21);
 4058                reason  = reason ?? 'цена ≤ 400 ₽: разница должна быть > 20 ₽';
 4059            } else if (current <= 10000) {
 4060                if (diff > tp * 0.05) break;
 4061                current = Math.max(1, Math.floor(tp * 0.949));
 4062                reason  = reason ?? 'цена 400–10 000 ₽: скидка должна быть > 5%';
 4063            } else {
 4064                if (diff > 500) break;
 4065                current = Math.max(1, tp - 501);
 4066                reason  = reason ?? 'цена > 10 000 ₽: разница должна быть > 500 ₽';
 4067            }
 4068        }
 4069        return current !== w
 4070            ? { price: current, corrected: true, originalPrice: w, reason }
 4071            : { price: w, corrected: false };
 4072    }
 4073
 4074    // Вычисляет old_price = newPrice * 1.25, но не ниже минимума по правилам скидок Ozon
 4075    function _computeOldPrice(yourPrice) {
 4076        const p = Math.round(yourPrice);
 4077        let minOld;
 4078        if (p <= 400)        minOld = p + 21;           // разница > 20 ₽
 4079        else if (p <= 10000) minOld = Math.ceil(p * 1.053); // разница > 5%
 4080        else                 minOld = p + 501;           // разница > 500 ₽
 4081        return Math.max(Math.round(yourPrice * 1.25), minOld);
 4082    }
 4083
 4084    // Возвращает читаемое описание ошибки Ozon по коду или тексту
 4085    function _translateOzonError(err) {
 4086        const code = (err.code ?? err.Code ?? '').toString();
 4087        const msg  = (err.message ?? err.Message ?? '').toString();
 4088        const combined = (code + ' ' + msg).toLowerCase();
 4089        if (code === 'discount_for_low_price_is_too_small' || combined.includes('цена не выше 400') || combined.includes
 4090            return 'Скидка слишком мала: при цене ≤ 400 ₽ разница между старой и новой ценой должна быть > 20 ₽';
 4091        if (code === 'discount_for_average_price_is_too_small')
 4092            return 'Скидка слишком мала: при цене 400–10 000 ₽ разница должна быть > 5%';
 4093        if (code === 'discount_for_top_price_is_too_small')
 4094            return 'Скидка слишком мала: при цене > 10 000 ₽ разница должна быть > 500 ₽';
 4095        if (code === 'discount_too_big')
 4096            return 'Скидка слишком большая: разница не может превышать 90% от старой цены';
 4097        if (code === 'invalid_category_price')
 4098            return 'Цена вне допустимого диапазона для этой категории товара';
 4099        if (code === 'price_negative')
 4100            return 'Попытка установить отрицательную цену';
 4101        if (code === 'NOT_FOUND_ERROR' || code === 'not_found_error')
 4102            return 'Товар не найден в личном кабинете Ozon (проверьте offer_id/product_id)';
 4103        if (code === 'action_price_enabled_min_price_missing')
 4104            return 'Автоприменение в акции включено — отключите его или укажите минимальную цену';
 4105        if (combined.includes('limit exceeded') || combined.includes('acquire limit') || combined.includes('items limit'
 4106            return 'Превышен лимит: максимум 10 обновлений цены в час на один товар';
 4107        return msg || code || JSON.stringify(err);
 4108    }
 4109
 4110    function _renderPriceOptWinnerBlock(uid, winner, winnerSrc, p0d, p1d, pMin, correction) {
 4111        if (winner == null) {
 4112            return `<div class="alert alert-secondary py-2 px-3 small mb-2">Победитель не определён — нет данных C₀/C₁.<
 4113        }
 4114        let reasonHtml = '';
 4115        if (pMin != null) {
 4116            const fmtD = v => v != null ? Math.abs(Math.round(v - pMin)).toLocaleString('ru-RU') : '—';
 4117            const r0 = p0d != null ? `|P₀−p − P_min| = |${_priceOptFmt(p0d)} − ${_priceOptFmt(pMin)}| = ${fmtD(p0d)} ₽` 
 4118            const r1 = p1d != null ? `|P₁−p − P_min| = |${_priceOptFmt(p1d)} − ${_priceOptFmt(pMin)}| = ${fmtD(p1d)} ₽` 
 4119            const lines = [r0, r1].filter(Boolean);
 4120            if (lines.length)
 4121                reasonHtml = `<div class="price-opt-winner-reason">${lines.join('<br>')}</div>`;
 4122        }
 4123        return `<div class="price-opt-winner-card mb-2">
 4124            <div class="d-flex align-items-center gap-2">
 4125                <i class="fas fa-trophy text-warning" style="font-size:1rem"></i>
 4126                <span class="text-muted small">Рекомендуемая цена</span>
 4127                <span id="priceOptWinner_${uid}" data-winner="${correction?.corrected ? correction.price : winner}">
 4128                    <strong class="fs-6" id="priceOptWinnerVal_${uid}">${_priceOptFmtRub(correction?.corrected ? correct
 4129                    <span class="text-muted small ms-1" id="priceOptWinnerSrc_${uid}">${winnerSrc ? `(из ${escapeHtml(wi
 4130                </span>
 4131            </div>
 4132            ${reasonHtml}
 4133            ${correction?.corrected ? `<div class="alert alert-warning py-1 px-2 small mt-2 mb-0 d-flex align-items-cent
 4134                   <i class="fas fa-exclamation-triangle flex-shrink-0"></i>
 4135                   <span>Скорректирована по правилам Ozon:
 4136                   <strong>${Math.round(correction.originalPrice).toLocaleString('ru-RU')} ₽ → ${Math.round(correction.p
 4137                   <span class="text-muted ms-1">(${correction.reason})</span></span>
 4138               </div>` : ''}
 4139        </div>`;
 4140    }
 4141
 4142    function _renderActiveMarkupBadge(inPromo, activeP) {
 4143        if (activeP === 0)
 4144            return `<span class="badge bg-light text-dark border"><i class="fas fa-ban me-1"></i>Снижение не применится 
 4145        if (inPromo)
 4146            return `<span class="badge bg-danger"><i class="fas fa-percent me-1"></i>Распродажа — p = <strong>${activeP}
 4147        return `<span class="badge bg-secondary"><i class="fas fa-minus-circle me-1"></i>Без распродажи — p = <strong>${
 4148    }
 4149
 4150    function renderPriceOptSection(opt, truePrice) {
 4151        if (!opt) return '';
 4152
 4153        const uid      = Date.now();
 4154        const inPromo  = !!opt.inPromo;
 4155        const promoName = opt.promoName || '';
 4156        const noPromoMarkup = Number(opt.noPromoMarkupPercent ?? 5);
 4157        const promoMarkup   = Number(opt.promoMarkupPercent   ?? 10);
 4158        const activeP       = inPromo ? promoMarkup : noPromoMarkup;
 4159
 4160        const c0         = opt.c0       != null ? Number(opt.c0)       : null;
 4161        const c1         = opt.c1       != null ? Number(opt.c1)       : null;
 4162        const rate       = opt.rate     != null ? Number(opt.rate)     : null;
 4163        const cFixed     = opt.cFixedMax != null ? Number(opt.cFixedMax) : null;
 4164        const salesFbs   = opt.salesPctFbs  != null ? Number(opt.salesPctFbs)  : null;
 4165        const acquiring  = opt.acquiring    != null ? Number(opt.acquiring)    : null;
 4166        const firstMile  = opt.firstMileMax  != null ? Number(opt.firstMileMax)  : null;
 4167        const directFlow = opt.directFlowMax != null ? Number(opt.directFlowMax) : null;
 4168        const lastMile   = opt.lastMile     != null ? Number(opt.lastMile)     : null;
 4169        const pMin       = opt.pMin != null ? Number(opt.pMin) : null;
 4170        const p0         = opt.p0   != null ? Number(opt.p0)  : null;
 4171        const p1         = opt.p1   != null ? Number(opt.p1)  : null;
 4172
 4173        // Данные для прямого SetProductPrice (из снимка, без повторного запроса к Ozon)
 4174        const snapProductId  = opt.productId != null ? String(opt.productId) : '';
 4175        const snapOfferId    = opt.offerId   ?? '';
 4176        const snapVat        = opt.vat       ?? '0';
 4177        const batchMult      = Number(opt.batchMultiplier ?? 1);
 4178        const hasBatch       = batchMult > 1;
 4179
 4180        // ── Заголовок
 4181        const promoStatusHtml = inPromo
 4182            ? `<span class="badge bg-danger"><i class="fas fa-percent me-1"></i>Распродажа</span>`
 4183            : `<span class="badge bg-secondary">Без распродажи</span>`;
 4184
 4185        let html = `<div class="price-opt-section" id="priceOptSection_${uid}"
 4186            data-client="${escapeHtml(opt.client || '')}"
 4187            data-product-id="${escapeHtml(snapProductId)}"
 4188            data-offer-id="${escapeHtml(snapOfferId)}"
 4189            data-vat="${escapeHtml(snapVat)}">
 4190        <div class="d-flex align-items-center justify-content-between mb-3">
 4191            <div class="d-flex align-items-center gap-2">
 4192                <i class="fas fa-calculator text-primary"></i>
 4193                <span class="fw-semibold">Оптимизация цены</span>
 4194                <span class="text-muted" style="font-size:0.75rem">Модель 2</span>
 4195            </div>
 4196            ${promoStatusHtml}
 4197        </div>`;
 4198
 4199        if (opt.error) {
 4200            html += `<div class="alert alert-danger py-2 px-3 small mb-0"><i class="fas fa-exclamation-circle me-1"></i>
 4201            return html;
 4202        }
 4203        if (c0 == null && c1 == null) {
 4204            html += `<div class="alert alert-secondary py-2 px-3 small mb-0"><i class="fas fa-info-circle me-1"></i>Данн
 4205            return html;
 4206        }
 4207
 4208        // ── Блок партии (показывается только при [N] > 1)
 4209        if (hasBatch) {
 4210            html += `<div class="alert alert-info py-2 px-3 mb-3 d-flex align-items-start gap-2" style="font-size:0.82re
 4211                <i class="fas fa-boxes mt-1 flex-shrink-0"></i>
 4212                <div>
 4213                    <strong>Товар продаётся в упаковке по ${batchMult} шт.</strong>
 4214                    <span class="ms-1 text-muted">(OfferId: <code>${escapeHtml(snapOfferId)}</code>)</span><br>
 4215                    <span class="text-muted">
 4216                        Цены C₀/C₁ из 1С — за <strong>1 шт.</strong>
 4217                        Итоговая цена в Ozon = цена победителя × ${batchMult}.
 4218                    </span>
 4219                </div>
 4220            </div>`;
 4221        }
 4222
 4223        // ── Шаг 1: Себестоимость
 4224        html += `<div class="mb-3">
 4225            <div class="price-opt-step-title">1. Себестоимость${hasBatch ? ` <span class="text-muted fw-normal" style="f
 4226            <div class="price-opt-table">
 4227                <div class="price-opt-row">
 4228                    <span class="price-opt-label">C₀</span>
 4229                    <span class="price-opt-value ${c0 == null ? 'text-muted' : 'fw-semibold'}">${_priceOptFmtRub(c0)}</s
 4230                    <span class="price-opt-note"></span>
 4231                </div>
 4232                <div class="price-opt-row">
 4233                    <span class="price-opt-label">C₁</span>
 4234                    <span class="price-opt-value ${c1 == null ? 'text-muted' : 'fw-semibold'}">${_priceOptFmtRub(c1)}</s
 4235                    <span class="price-opt-note"></span>
 4236                </div>
 4237            </div>
 4238        </div>`;
 4239
 4240        // ── Шаг 2: Параметры Ozon
 4241        const rateFormulaParts = [salesFbs != null ? _priceOptFmtPct1(salesFbs) : null, acquiring != null ? _priceOptFmt
 4242        const cFixedParts = [firstMile != null ? _priceOptFmt(firstMile) : null, directFlow != null ? _priceOptFmt(direc
 4243
 4244        html += `<div class="mb-3">
 4245            <div class="price-opt-step-title">2. Параметры Ozon</div>
 4246            <div class="price-opt-table">
 4247                ${salesFbs != null  ? `<div class="price-opt-row"><span class="price-opt-label">FBS комиссия</span><span
 4248                ${acquiring != null ? `<div class="price-opt-row"><span class="price-opt-label">Эквайринг</span><span cl
 4249                <div class="price-opt-row price-opt-subtotal">
 4250                    <span class="price-opt-label fw-semibold">Ставка = FBS + эквайр.</span>
 4251                    <span class="price-opt-value fw-semibold">${_priceOptFmtPct2(rate != null ? rate * 100 : null)}</spa
 4252                    ${rateFormulaParts.length > 1 ? `<span class="price-opt-note">${rateFormulaParts.join(' + ')}</span>
 4253                </div>
 4254                ${firstMile  != null ? `<div class="price-opt-row"><span class="price-opt-label">1-я миля FBS (max)</spa
 4255                ${directFlow != null ? `<div class="price-opt-row"><span class="price-opt-label">Прямой поток FBS (max)<
 4256                ${lastMile   != null ? `<div class="price-opt-row"><span class="price-opt-label">Последняя миля</span><s
 4257                <div class="price-opt-row price-opt-subtotal">
 4258                    <span class="price-opt-label fw-semibold">C_fixed = 1-я + поток + посл.</span>
 4259                    <span class="price-opt-value fw-semibold">${_priceOptFmtRub(cFixed)}</span>
 4260                    ${cFixedParts.length > 1 ? `<span class="price-opt-note">${cFixedParts.join(' + ')} ₽</span>` : ''}
 4261                </div>
 4262                <div class="price-opt-row">
 4263                    <span class="price-opt-label">P_min (порог конкурентов)</span>
 4264                    <span class="price-opt-value ${pMin != null ? 'text-primary fw-semibold' : 'text-muted'}">${_priceOp
 4265                </div>
 4266            </div>
 4267        </div>`;
 4268
 4269        // ── Шаг 3: Базовые цены
 4270        const rateDisp  = rate != null ? (rate * 100).toFixed(2) + '%' : '?';
 4271        const cFixedN   = cFixed != null ? _priceOptFmt(cFixed) : '?';
 4272        const p0CardCls = p0 == null ? 'opacity-50' : '';
 4273        const p1CardCls = p1 == null ? 'opacity-50' : '';
 4274
 4275        html += `<div class="mb-3">
 4276            <div class="price-opt-step-title">3. Базовые цены P = (C + C_fixed) / (1 − ставка)</div>
 4277            <div class="price-opt-formula-card ${p0CardCls}">
 4278                <div class="price-opt-formula-expr">P₀ = (C₀ + C_fixed) / (1 − ставка)</div>
 4279                <div class="price-opt-formula-sub">${c0 != null ? `= (${_priceOptFmt(c0)} + ${cFixedN}) / (1 − ${rateDis
 4280                <div class="price-opt-formula-result">${_priceOptFmtRub(p0)}</div>
 4281            </div>
 4282            <div class="price-opt-formula-card ${p1CardCls}">
 4283                <div class="price-opt-formula-expr">P₁ = (C₁ + C_fixed) / (1 − ставка)</div>
 4284                <div class="price-opt-formula-sub">${c1 != null ? `= (${_priceOptFmt(c1)} + ${cFixedN}) / (1 − ${rateDis
 4285                <div class="price-opt-formula-result">${_priceOptFmtRub(p1)}</div>
 4286            </div>
 4287        </div>`;
 4288
 4289        // ── Шаг 4: Надбавка p
 4290        const _p0dSub = p0 != null
 4291            ? (activeP === 0 ? `= ${_priceOptFmt(p0)} (снижение не применится)` : `= ${_priceOptFmt(p0)} × (1 − ${active
 4292            : '— (P₀ недоступна)';
 4293        const _p1dSub = p1 != null
 4294            ? (activeP === 0 ? `= ${_priceOptFmt(p1)} (снижение не применится)` : `= ${_priceOptFmt(p1)} × (1 − ${active
 4295            : '— (P₁ недоступна)';
 4296        const _step4Title = activeP === 0
 4297            ? '4. Надбавка к цене (p) — не применяется'
 4298            : '4. Надбавка к цене (p) — P − p = P × (1 − p/100)';
 4299        html += `<div class="mb-3">
 4300            <div class="price-opt-step-title">${_step4Title}</div>
 4301            <div class="d-flex align-items-center gap-3 mb-2 small flex-wrap">
 4302                <label class="d-flex align-items-center gap-1 mb-0 ${!inPromo ? 'fw-semibold' : 'text-muted'}">
 4303                    ${!inPromo ? '<i class="fas fa-check text-success me-1"></i>' : ''}
 4304                    Без распродажи:
 4305                    <input type="number" id="priceOptNoPromo_${uid}"
 4306                        class="form-control form-control-sm price-opt-markup-input ms-1"
 4307                        data-uid="${uid}" style="width:65px" min="0" max="99" step="0.1" value="${noPromoMarkup}"> %
 4308                </label>
 4309                <label class="d-flex align-items-center gap-1 mb-0 ${inPromo ? 'fw-semibold' : 'text-muted'}">
 4310                    ${inPromo ? '<i class="fas fa-check text-danger me-1"></i>' : ''}
 4311                    Распродажа:
 4312                    <input type="number" id="priceOptPromo_${uid}"
 4313                        class="form-control form-control-sm price-opt-markup-input ms-1"
 4314                        data-uid="${uid}" style="width:65px" min="0" max="99" step="0.1" value="${promoMarkup}"> %
 4315                </label>
 4316            </div>
 4317            <div class="price-opt-active-badge mb-2" id="priceOptActiveMarkup_${uid}">
 4318                ${_renderActiveMarkupBadge(inPromo, activeP)}
 4319            </div>
 4320            <div class="price-opt-formula-card ${p0CardCls}">
 4321                <div class="price-opt-formula-expr">${activeP === 0 ? 'P₀ − p = P₀' : 'P₀ − p = P₀ × (1 − p/100)'}</div>
 4322                <div class="price-opt-formula-sub" id="priceOptP0dFormula_${uid}">${_p0dSub}</div>
 4323                <div class="price-opt-formula-result" id="priceOptP0d_${uid}">${_priceOptFmtRub(opt.p0Discounted)}</div>
 4324            </div>
 4325            <div class="price-opt-formula-card ${p1CardCls}">
 4326                <div class="price-opt-formula-expr">${activeP === 0 ? 'P₁ − p = P₁' : 'P₁ − p = P₁ × (1 − p/100)'}</div>
 4327                <div class="price-opt-formula-sub" id="priceOptP1dFormula_${uid}">${_p1dSub}</div>
 4328                <div class="price-opt-formula-result" id="priceOptP1d_${uid}">${_priceOptFmtRub(opt.p1Discounted)}</div>
 4329            </div>
 4330        </div>`;
 4331
 4332        // data-контейнер для live-пересчёта
 4333        html += `<div id="priceOptData_${uid}" style="display:none"
 4334            data-p0="${p0 ?? ''}" data-p1="${p1 ?? ''}"
 4335            data-pmin="${pMin ?? ''}" data-inpromo="${inPromo ? '1' : '0'}"
 4336            data-trueprice="${truePrice ?? ''}"></div>`;
 4337
 4338        // ── Шаг 5: Победитель
 4339        // Проверка winner по правилам Ozon скидок
 4340        const winner    = opt.winnerPrice;
 4341        const winnerSrc = opt.winnerSource ?? '';
 4342        const p0d       = opt.p0Discounted != null ? Number(opt.p0Discounted) : null;
 4343        const p1d       = opt.p1Discounted != null ? Number(opt.p1Discounted) : null;
 4344
 4345        const correction = _validateAndAdjustWinner(winner, truePrice);
 4346        const finalWinner = winner != null && hasBatch ? winner * batchMult : winner;
 4347        html += `<div class="mb-3">
 4348            <div class="price-opt-step-title">5. Победитель = argmin |P − p − P_min|</div>
 4349            <div id="priceOptWinnerCard_${uid}">
 4350                ${_renderPriceOptWinnerBlock(uid, winner, winnerSrc, p0d, p1d, pMin, correction)}
 4351            </div>
 4352            ${hasBatch && winner != null ? `<div class="price-opt-formula-card mt-2" style="background:var(--bs-info-bg-
 4353                <div class="price-opt-formula-expr" style="color:var(--bs-info-text-emphasis,#055160)">
 4354                    <i class="fas fa-boxes me-1"></i>Цена в Ozon = победитель × ${batchMult}
 4355                </div>
 4356                <div class="price-opt-formula-sub">${_priceOptFmt(winner)} × ${batchMult}</div>
 4357                <div class="price-opt-formula-result">${Math.round(finalWinner).toLocaleString('ru-RU')} ₽</div>
 4358            </div>` : ''}
 4359        </div>`;
 4360
 4361        html += `<details class="mb-2" style="font-size:0.78rem">
 4362            <summary class="text-muted" style="cursor:pointer">Правила установки цен Ozon</summary>
 4363            <ul class="mt-1 mb-0 ps-3 text-muted" style="line-height:1.6">
 4364                <li>Цена ≤ 400 ₽ — разница между старой и новой ценой должна быть <strong>&gt; 20 ₽</strong></li>
 4365                <li>Цена 400–10 000 ₽ — разница должна быть <strong>&gt; 5%</strong></li>
 4366                <li>Цена &gt; 10 000 ₽ — разница должна быть <strong>&gt; 500 ₽</strong></li>
 4367                <li>Скидка не может превышать <strong>90%</strong> от старой цены</li>
 4368                <li>Максимум <strong>10 обновлений цены</strong> в час на один товар</li>
 4369                <li>Если включено автоприменение в акции — обязательно укажите минимальную цену</li>
 4370            </ul>
 4371        </details>
 4372        <div id="priceOptPushArea_${uid}">
 4373            <div class="d-flex gap-2" id="priceOptPushBtns_${uid}">
 4374                <button class="btn btn-primary btn-sm price-opt-push-btn" data-uid="${uid}"
 4375                    title="Отправить рекомендуемую цену в Ozon">
 4376                    <i class="fas fa-upload me-1"></i>Накатить рекомендованную цену
 4377                </button>
 4378            </div>
 4379            <div id="priceOptPushResult_${uid}" class="mt-2"></div>
 4380        </div>`;
 4381
 4382        html += '</div>';
 4383        return html;
 4384    }
 4385
 4386    // Цены Ozon по клиентам — грид AG Grid: пары «цена → значение» и «комиссия/индекс → значение»,
 4387    // ячейка клиента объединена по его строкам. Разметка — контейнер, грид монтирует mountSyncOzonPriceGrid().
 4388    let _ozonPriceGridRows = [];
 4389    let _ozonPriceGridApi = null;
 4390
 4391    function renderSyncOzonPriceTable(data, c0, c1, promoInfo, priceSettings) {
 4392        _ozonPriceGridRows = [];
 4393        if (!data || !data.length) return St2EmptyState.markup({ compact: true, title: 'Нет данных', description: 'Для в
 4394
 4395        data.forEach((entry) => {
 4396            const item = entry.Item || {};
 4397            const p    = item.price || {};
 4398            const c    = item.commissions || {};
 4399            const indx = item.price_indexes || {};
 4400            const clientName = entry.Client || '-';
 4401
 4402            const priceTypes = [
 4403                {name:'Цена с Ozon Картой',          value: p.customer_price ?? p.true_price ?? '-', style:'color:orange
 4404                {name:'Реком. цена с картой',         value: p.green_target_customer_price ?? '-',   cls:'text-warning'}
 4405                {name:'Реком. цена продавца',         value: p.green_target_seller_price   ?? '-',   cls:'text-warning'}
 4406                {name:'Как рассчитана реком.',        value: p.green_target_formula        ?? '-',   cls:'text-muted', w
 4407                {name:'Кандидаты расчета',            value: p.green_target_candidates     ?? '-',   cls:'text-muted', w
 4408                {name:'Как рассчитана с картой',      value: p.customer_price_formula      ?? '-',   cls:'text-muted', w
 4409                {name:'Причина нет рекомендации',     value: p.green_target_error          ?? '-',   cls:'text-danger', 
 4410                {name:'Комиссия макс.',               value: p.max_commission ?? '-',                cls:'text-success'}
 4411                {name:'Комиссия мин.',                value: p.min_commission ?? '-',                cls:'text-danger'},
 4412                {name:'Вознаграждение Ozon',          value: p.ozon_reward ?? '-'},
 4413                {name:'Цена (со скидками)',            value: p.price ?? '-'},
 4414                {name:'Старая цена',                  value: p.old_price ?? '-'},
 4415                {name:'Цена с акциями Ozon',          value: p.marketing_price ?? '-'},
 4416                {name:'Цена с акциями продавца',      value: p.marketing_seller_price ?? '-'},
 4417                {name:'Мин. цена после скидок',       value: p.min_price ?? '-'},
 4418                {name:'Себестоимость',                value: p.net_price ?? '-'},
 4419                {name:'Цена поставщика',              value: p.retail_price ?? '-'},
 4420                {name:'Скидка до цены с картой, %',   value: p.customer_discount_percent ?? '-'},
 4421                {name:'Ставка НДС',                   value: p.vat ?? '-'},
 4422                {name:'Валюта',                       value: p.currency_code ?? '-'},
 4423                {name:'Автоприменение акций',         value: p.auto_action_enabled ? 'Да' : 'Нет'},
 4424                {name:'Автодобавление в акции',       value: p.auto_add_to_ozon_actions_list_enabled ? 'Да' : 'Нет'},
 4425            ];
 4426
 4427            const commissionTypes = [
 4428                {name:'Индекс цены',                        html: syncColorIndexDesc(indx.color_index)},
 4429                {name:'Целевой индекс',                     value: p.green_target_index ?? '-'},
 4430                {name:'Ограничивающий рынок',               value: p.green_target_source ?? '-'},
 4431                {name:'Цена ориентира',                     value: p.green_target_source_price ?? '-'},
 4432                {name:'Индекс ориентира',                   value: p.green_target_source_index ?? '-'},
 4433                {name:'Мин. цена конкурентов Ozon',         value: indx.ozon_index_data?.min_price ?? '-'},
 4434                {name:'Индекс цены на Ozon',                value: indx.ozon_index_data?.price_index_value ?? '-'},
 4435                {name:'Мин. цена конкурентов др. площадки', value: indx.external_index_data?.min_price ?? '-'},
 4436                {name:'Индекс цены др. площадки',           value: indx.external_index_data?.price_index_value ?? '-'},
 4437                {name:'Мин. ваш товар на др. площадках',    value: indx.self_marketplaces_index_data?.min_price ?? '-'},
 4438                {name:'Индекс на ваших площадках',          value: indx.self_marketplaces_index_data?.price_index_value 
 4439                {name:'Комиссия за продажу FBS',            value: p.ozon_reward ?? '-'},
 4440                {name:'% комиссии за продажу FBS',          value: c.sales_percent_fbs ?? '-'},
 4441                {name:'Эквайринг',                          value: item.acquiring != null ? Math.round(Number(item.acqui
 4442                {name:'Макс. комиссия обработки FBS',       value: c.fbs_first_mile_max_amount ?? '-'},
 4443                {name:'Мин. комиссия обработки FBS',        value: c.fbs_first_mile_min_amount ?? '-'},
 4444                {name:'Магистраль до FBS',                  value: c.fbs_direct_flow_trans_max_amount ?? '-'},
 4445                {name:'Последняя миля FBS',                 value: c.fbs_deliv_to_customer_amount ?? '-'},
 4446                {name:'Возврат/отмена FBS',                 value: c.fbs_return_flow_amount ?? '-'},
 4447                {name:'Магистраль от FBS',                  value: c.fbs_direct_flow_trans_min_amount ?? '-'},
 4448            ];
 4449
 4450            const maxRows = Math.max(priceTypes.length, commissionTypes.length);
 4451            for (let i = 0; i < maxRows; i++) {
 4452                _ozonPriceGridRows.push({ id: `${clientName}#${i}`, client: clientName, first: i === 0, span: maxRows, p
 4453            }
 4454        });
 4455
 4456        return '<div id="syncOzonPricesGrid" class="bsx-grid bsx-grid--auto ozon-prices-table"></div>';
 4457    }
 4458
 4459    function mountSyncOzonPriceGrid() {
 4460        const el = document.getElementById('syncOzonPricesGrid');
 4461        if (_ozonPriceGridApi) { try { _ozonPriceGridApi.destroy(); } catch (_) { } _ozonPriceGridApi = null; }
 4462        if (!el || !window.BsxGrid) return;
 4463        const style = pr => pr?.style || '';
 4464        _ozonPriceGridApi = window.BsxGrid.create(el, {
 4465            key: 'ozonPrices',
 4466            rowData: _ozonPriceGridRows,
 4467            getRowId: p => p.data.id,
 4468            defaultColDef: { sortable: false, filter: false, floatingFilter: false },
 4469            gridOptions: { domLayout: 'autoHeight', suppressRowTransform: true, rowHeight: 28 },
 4470            columnDefs: [
 4471                { colId: 'client', headerName: 'Клиент', width: 110, pinned: 'left', field: 'client',
 4472                  rowSpan: p => p.data?.first ? p.data.span : 1,
 4473                  cellClassRules: { 'price-client-cell bsx-span-cell': p => !!p.data?.first },
 4474                  cellRenderer: p => p.data?.first ? escapeHtml(p.value) : '' },
 4475                { colId: 'priceName', headerName: 'Цена', width: 210,
 4476                  cellRenderer: p => p.data?.pr ? `<span class="text-muted small ${p.data.pr.cls || ''}" style="${style(
 4477                { colId: 'priceValue', headerName: 'Значение', width: 150, wrapText: true, autoHeight: true, cellClass: 
 4478                  cellRenderer: p => {
 4479                      const pr = p.data?.pr; if (!pr) return '';
 4480                      return `<span class="${pr.cls || ''}" style="${style(pr)}">${pr.wide ? `<span class="text-wrap" st
 4481                  } },
 4482                { colId: 'commName', headerName: 'Комиссия / индекс', width: 230, cellClass: 'comm-separator',
 4483                  cellRenderer: p => p.data?.cm ? `<span class="text-muted small">${escapeHtml(p.data.cm.name)}</span>` 
 4484                { colId: 'commValue', headerName: 'Значение', flex: 1, minWidth: 110, cellClass: 'comm-val-cell',
 4485                  cellRenderer: p => p.data?.cm ? (p.data.cm.html ?? syncFmtVal(p.data.cm.value)) : '' }
 4486            ]
 4487        });
 4488    }
 4489
 4490    // Загружает (или перезагружает) содержимое модалки цен для текущего артикула.
 4491    // При reload=true — берёт живые данные из Ozon вместо снимка.
 4492    async function _loadPriceModal(article, reportId, clientName, reload) {
 4493        $('#syncOzonPricesBody').html('<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-p
 4494
 4495        try {
 4496            const ozonUrl = reload && clientName
 4497                ? `/Studio2/BitrixStock/GetLiveOzonPricesForModal?clientName=${encodeURIComponent(clientName)}&article=$
 4498                : `/Studio2/BitrixStock/GetSyncReportOzonPrices?reportId=${encodeURIComponent(reportId)}&article=${encod
 4499
 4500            const [ozonResp, optResp] = await Promise.allSettled([
 4501                fetch(ozonUrl),
 4502                fetch(priceOptimizationUrl(reportId, article))
 4503            ]);
 4504
 4505            let ozonData = null, optData = null;
 4506            if (ozonResp.status === 'fulfilled' && ozonResp.value.ok)
 4507                ozonData = await ozonResp.value.json();
 4508            else if (ozonResp.status === 'rejected')
 4509                throw new Error(ozonResp.reason?.message ?? 'Ошибка загрузки цен Ozon');
 4510
 4511            if (optResp.status === 'fulfilled' && optResp.value.ok) {
 4512                const arr = await optResp.value.json();
 4513                optData = Array.isArray(arr) ? arr[0] : null;
 4514            }
 4515
 4516            if (!ozonData || !ozonData.length) {
 4517                $('#syncOzonPricesBody').html(St2EmptyState.markup({ compact: true, title: 'Нет данных цен Ozon', descri
 4518                return;
 4519            }
 4520            const priceKey = `${article}_${_priceModalClientId}`;
 4521            const truePrice = syncPriceInfoMap.get(priceKey)?.truePrice ?? null;
 4522            // Показываем ×N в заголовке если товар-упаковка
 4523            const _batchN = optData?.batchMultiplier ? Number(optData.batchMultiplier) : 1;
 4524            const _batchBadge = document.getElementById('syncOzonPricesBatchBadge');
 4525            if (_batchBadge) {
 4526                if (_batchN > 1) { _batchBadge.textContent = `×${_batchN} шт.`; _batchBadge.style.display = ''; }
 4527                else _batchBadge.style.display = 'none';
 4528            }
 4529            $('#syncOzonPricesBody').html(renderSyncOzonPriceTable(ozonData) + renderPriceOptSection(optData, truePrice)
 4530            mountSyncOzonPriceGrid();
 4531
 4532            // После обновления БД освежаем in-memory карту цен для таблицы отчёта
 4533            if (reload && reportId) {
 4534                fetch(`/Studio2/BitrixStock/GetSyncReportPriceInfo?reportId=${encodeURIComponent(reportId)}`)
 4535                    .then(r => r.ok ? r.json() : [])
 4536                    .then(arr => {
 4537                        syncPriceInfoMap = new Map();
 4538                        for (const e of arr) syncPriceInfoMap.set(`${e.article}_${e.ozonClientId}`, e);
 4539                    })
 4540                    .catch(() => {});
 4541            }
 4542        } catch (err) {
 4543            $('#syncOzonPricesBody').html(`<p class="text-danger p-3">Ошибка загрузки: ${escapeHtml(err.message)}</p>`);
 4544        }
 4545    }
 4546
 4547    $(document).on('click', '.sync-ozon-price-btn', async function (e) {
 4548        e.preventDefault();
 4549        const article  = $(this).data('article');
 4550        const reportId = $(this).data('report-id');
 4551        _priceModalClientId = parseInt($(this).data('client-id')) || 0;
 4552        _priceModalArticle  = article;
 4553        _priceModalReportId = reportId;
 4554        $('#syncOzonPricesArticle').text(article);
 4555        const _batchBadgeEl = document.getElementById('syncOzonPricesBatchBadge');
 4556        if (_batchBadgeEl) _batchBadgeEl.style.display = 'none'; // сброс до загрузки
 4557        const _priceModalEl = document.getElementById('syncOzonPricesModal');
 4558        let _priceModal = bootstrap.Modal.getInstance(_priceModalEl);
 4559        if (!_priceModal) {
 4560            _priceModal = new bootstrap.Modal(_priceModalEl, { backdrop: false });
 4561        }
 4562        _priceModal.show();
 4563        await _loadPriceModal(article, reportId, null, false);
 4564    });
 4565
 4566    // Live-пересчёт секции оптимизации при изменении процентов
 4567    $(document).on('change input', '.price-opt-markup-input', function () {
 4568        const uid     = $(this).data('uid');
 4569        const dataEl  = document.getElementById(`priceOptData_${uid}`);
 4570        if (!dataEl) return;
 4571
 4572        const d       = dataEl.dataset;
 4573        const p0      = d.p0   ? parseFloat(d.p0)   : null;
 4574        const p1      = d.p1   ? parseFloat(d.p1)   : null;
 4575        const pMin    = d.pmin ? parseFloat(d.pmin) : null;
 4576        const inPromo = d.inpromo === '1';
 4577
 4578        const noPromoMarkup = (v => isNaN(v) ? 5 : v)(parseFloat(document.getElementById(`priceOptNoPromo_${uid}`)?.valu
 4579        const promoMarkup   = (v => isNaN(v) ? 10 : v)(parseFloat(document.getElementById(`priceOptPromo_${uid}`)?.value
 4580        const activeP       = inPromo ? promoMarkup : noPromoMarkup;
 4581
 4582        const p0d = p0 != null ? p0 * (1 - activeP / 100) : null;
 4583        const p1d = p1 != null ? p1 * (1 - activeP / 100) : null;
 4584
 4585        // Обновляем active markup badge
 4586        const activeEl = document.getElementById(`priceOptActiveMarkup_${uid}`);
 4587        if (activeEl) activeEl.innerHTML = _renderActiveMarkupBadge(inPromo, activeP);
 4588
 4589        // Обновляем формулы P0d и P1d
 4590        const fmtN = v => v != null ? Math.round(v).toLocaleString('ru-RU') : '?';
 4591        const fmtR = v => v != null ? Math.round(v).toLocaleString('ru-RU') + ' ₽' : '—';
 4592
 4593        const p0dFmEl = document.getElementById(`priceOptP0dFormula_${uid}`);
 4594        if (p0dFmEl) p0dFmEl.textContent = p0 != null
 4595            ? (activeP === 0 ? `= ${fmtN(p0)} (снижение не применится)` : `= ${fmtN(p0)} × (1 − ${activeP}/100)`)
 4596            : '— (P₀ недоступна)';
 4597        const p0dEl = document.getElementById(`priceOptP0d_${uid}`);
 4598        if (p0dEl) p0dEl.textContent = fmtR(p0d);
 4599
 4600        const p1dFmEl = document.getElementById(`priceOptP1dFormula_${uid}`);
 4601        if (p1dFmEl) p1dFmEl.textContent = p1 != null
 4602            ? (activeP === 0 ? `= ${fmtN(p1)} (снижение не применится)` : `= ${fmtN(p1)} × (1 − ${activeP}/100)`)
 4603            : '— (P₁ недоступна)';
 4604        const p1dEl = document.getElementById(`priceOptP1d_${uid}`);
 4605        if (p1dEl) p1dEl.textContent = fmtR(p1d);
 4606
 4607        // Пересчитываем победителя
 4608        const candidates = [{val: p0d, label: 'C₀'}, {val: p1d, label: 'C₁'}].filter(c => c.val != null);
 4609        let winner = null, winnerSrc = '';
 4610        if (candidates.length) {
 4611            if (pMin == null) { winner = candidates[0].val; winnerSrc = candidates[0].label; }
 4612            else {
 4613                const best = candidates.reduce((a, b) => Math.abs(a.val - pMin) <= Math.abs(b.val - pMin) ? a : b);
 4614                winner = best.val; winnerSrc = best.label;
 4615            }
 4616        }
 4617        const truePrice2 = d.trueprice ? parseFloat(d.trueprice) : null;
 4618        const correction2 = _validateAndAdjustWinner(winner, truePrice2);
 4619        const winnerCardEl = document.getElementById(`priceOptWinnerCard_${uid}`);
 4620        if (winnerCardEl) winnerCardEl.innerHTML = _renderPriceOptWinnerBlock(uid, winner, winnerSrc, p0d, p1d, pMin, co
 4621    });
 4622
 4623    // ── Overlay-анимация наката цены (живёт поверх .modal-content, не затирается _loadPriceModal) ──
 4624    function _createPushOverlay() {
 4625        document.getElementById('syncPricesPushOverlay')?.remove();
 4626        const mc = document.querySelector('#syncOzonPricesModal .modal-content');
 4627        if (!mc) return;
 4628        mc.style.position = 'relative';
 4629        const ov = document.createElement('div');
 4630        ov.id = 'syncPricesPushOverlay';
 4631        ov.className = 'push-overlay';
 4632        ov.innerHTML = `
 4633            <div class="push-overlay-inner">
 4634                <div class="push-steps mb-4">
 4635                    <div class="push-step" id="pushStep1">
 4636                        <span class="push-step-icon"><span class="spinner-border spinner-border-sm text-primary"></span>
 4637                        <span class="push-step-label">Отправка цены в Ozon</span>
 4638                    </div>
 4639                    <div class="push-step push-step-pending" id="pushStep2">
 4640                        <span class="push-step-icon"><i class="far fa-circle"></i></span>
 4641                        <span class="push-step-label">Получение данных из Ozon</span>
 4642                    </div>
 4643                    <div class="push-step push-step-pending" id="pushStep3">
 4644                        <span class="push-step-icon"><i class="far fa-circle"></i></span>
 4645                        <span class="push-step-label">Применение результата</span>
 4646                    </div>
 4647                </div>
 4648                <div class="progress" style="height:4px;border-radius:2px">
 4649                    <div id="pushOverlayBar"
 4650                         class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
 4651                         style="width:10%;transition:width .5s ease"></div>
 4652                </div>
 4653            </div>`;
 4654        mc.appendChild(ov);
 4655    }
 4656
 4657    function _setPushStep(n, state) {
 4658        const el = document.getElementById(`pushStep${n}`);
 4659        if (!el) return;
 4660        const iconEl = el.querySelector('.push-step-icon');
 4661        el.className = 'push-step' + (state === 'pending' ? ' push-step-pending' : state === 'done' ? ' push-step-done' 
 4662        if (iconEl) {
 4663            if (state === 'done')        iconEl.innerHTML = '<i class="fas fa-check-circle text-success"></i>';
 4664            else if (state === 'active') iconEl.innerHTML = '<span class="spinner-border spinner-border-sm text-primary"
 4665            else                         iconEl.innerHTML = '<i class="far fa-circle text-muted"></i>';
 4666        }
 4667        const bar = document.getElementById('pushOverlayBar');
 4668        if (bar) {
 4669            if (state === 'active') bar.style.width = ({ 1: '15%', 2: '55%', 3: '85%' })[n] ?? bar.style.width;
 4670            if (state === 'done' && n === 3) {
 4671                bar.style.width = '100%';
 4672                bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
 4673                bar.classList.add('bg-success');
 4674            }
 4675        }
 4676    }
 4677
 4678    function _removePushOverlay() {
 4679        document.getElementById('syncPricesPushOverlay')?.remove();
 4680    }
 4681
 4682    function _setPushResult(uid, success, oldPrice, newPrice, errMsg) {
 4683        const resultEl = uid != null
 4684            ? document.getElementById(`priceOptPushResult_${uid}`)
 4685            : document.querySelector('[id^="priceOptPushResult_"]');
 4686        const btnsEl = uid != null
 4687            ? document.getElementById(`priceOptPushBtns_${uid}`)
 4688            : document.querySelector('[id^="priceOptPushBtns_"]');
 4689        if (btnsEl) btnsEl.classList.remove('d-none');
 4690        if (!resultEl) return;
 4691        if (success) {
 4692            const fmt = v => v != null ? Math.round(v).toLocaleString('ru-RU') + ' ₽' : null;
 4693            const oldFmt = fmt(oldPrice), newFmt = fmt(newPrice);
 4694            const changeHtml = (oldFmt && newFmt)
 4695                ? `<div class="small mt-1 opacity-75">${oldFmt} → <strong>${newFmt}</strong></div>`
 4696                : newFmt ? `<div class="small mt-1 opacity-75"><strong>${newFmt}</strong></div>` : '';
 4697            resultEl.innerHTML = `<div class="alert alert-success py-2 px-3 mb-0 d-flex align-items-center gap-2">
 4698                <i class="fas fa-check-circle fs-5 flex-shrink-0"></i>
 4699                <div><div class="fw-semibold small">Цена обновлена</div>${changeHtml}</div>
 4700            </div>`;
 4701        } else {
 4702            resultEl.innerHTML = `<div class="alert alert-danger py-2 px-3 mb-0 d-flex align-items-start gap-2">
 4703                <i class="fas fa-exclamation-circle fs-5 flex-shrink-0 mt-1"></i>
 4704                <div class="small">${escapeHtml(errMsg ?? 'Неизвестная ошибка')}</div>
 4705            </div>`;
 4706        }
 4707    }
 4708
 4709    // Кнопка «Накатить рекомендованную цену»
 4710    $(document).on('click', '.price-opt-push-btn', async function () {
 4711        const uid     = $(this).data('uid');
 4712        const article = $('#syncOzonPricesArticle').text().trim();
 4713        if (!article) return;
 4714
 4715        const sectionEl  = document.getElementById(`priceOptSection_${uid}`);
 4716        const clientName = sectionEl?.dataset.client?.trim() || '';
 4717        if (!clientName) { _setPushResult(uid, false, null, null, 'Клиент Ozon не определён.'); return; }
 4718
 4719        const snapProductId = sectionEl?.dataset.productId ? Number(sectionEl.dataset.productId) : null;
 4720        const snapOfferId   = sectionEl?.dataset.offerId ?? '';
 4721        const snapVat       = sectionEl?.dataset.vat ?? '0';
 4722
 4723        const winnerEl = document.getElementById(`priceOptWinner_${uid}`);
 4724        const winner   = winnerEl ? parseFloat(winnerEl.dataset.winner) : null;
 4725        if (!winner || isNaN(winner)) { _setPushResult(uid, false, null, null, 'Нет рекомендованной цены.'); return; }
 4726
 4727        const priceKey       = `${article}_${_priceModalClientId}`;
 4728        const priceEntry     = syncPriceInfoMap.get(priceKey);
 4729        const oldPrice       = priceEntry?.truePrice ?? null;
 4730        const truePrice      = oldPrice;
 4731        const colorIndexBefore = priceEntry?.colorIndex ?? null;
 4732        const dataEl         = document.getElementById(`priceOptData_${uid}`);
 4733        const competitorPrice = dataEl?.dataset?.pmin ? parseFloat(dataEl.dataset.pmin) : null;
 4734        const _noPromoV = (v => isNaN(v) ? 5 : v)(parseFloat(document.getElementById(`priceOptNoPromo_${uid}`)?.value));
 4735        const _promoV   = (v => isNaN(v) ? 10 : v)(parseFloat(document.getElementById(`priceOptPromo_${uid}`)?.value));
 4736        const _inPromoD = dataEl?.dataset?.inpromo === '1';
 4737        const markupPct = _inPromoD ? _promoV : _noPromoV;
 4738
 4739        // ── Запускаем overlay-анимацию (живёт поверх modal-content, не затирается при reload) ──
 4740        _createPushOverlay();
 4741        // Шаг 1 уже активен (spinner показан в _createPushOverlay)
 4742
 4743        try {
 4744            // ── Шаг 1: отправляем цену ──
 4745            const resp = await fetch('/Studio2/BitrixStock/SetProductPrice', {
 4746                method: 'POST',
 4747                headers: {
 4748                    'Content-Type': 'application/json',
 4749                    'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() || ''
 4750                },
 4751                body: JSON.stringify({
 4752                    article,
 4753                    clientName,
 4754                    yourPrice:     Math.round(winner),
 4755                    oldPrice:      _computeOldPrice(winner),
 4756                    minPrice:      Math.round(winner),
 4757                    productId:     snapProductId,
 4758                    offerId:       snapOfferId || undefined,
 4759                    vat:           snapVat || '0',
 4760                    reportId:          _priceModalReportId || null,
 4761                    ozonClientId:      _priceModalClientId || 0,
 4762                    previousPrice:     oldPrice ?? undefined,
 4763                    markupPct:         markupPct,
 4764                    colorIndexBefore:  colorIndexBefore ?? undefined,
 4765                    competitorPrice:   (competitorPrice != null && !isNaN(competitorPrice)) ? competitorPrice : undefine
 4766                })
 4767            });
 4768
 4769            const result = await resp.json();
 4770            if (!resp.ok) {
 4771                _removePushOverlay();
 4772                _setPushResult(uid, false, oldPrice, null, result?.error ?? result?.message ?? `HTTP ${resp.status}`);
 4773                return;
 4774            }
 4775
 4776            const ozonResult = result?.result?.[0] ?? result?.Result?.[0];
 4777            const ozonErrors = ozonResult?.errors ?? ozonResult?.Errors ?? [];
 4778            if (ozonErrors.length > 0) {
 4779                _removePushOverlay();
 4780                _setPushResult(uid, false, oldPrice, null, `Ozon: ${ozonErrors.map(e => _translateOzonError(e)).join('; 
 4781                return;
 4782            }
 4783
 4784            // ── Шаг 1 выполнен → Шаг 2 ──
 4785            _setPushStep(1, 'done');
 4786            _setPushStep(2, 'active');
 4787            await new Promise(r => setTimeout(r, 350));
 4788
 4789            const newPrice = Math.round(winner);
 4790            _updateTablePriceCell(article, _priceModalClientId, newPrice, null);
 4791
 4792            // _loadPriceModal меняет #syncOzonPricesBody — overlay остаётся сверху
 4793            await _loadPriceModal(article, _priceModalReportId, clientName, true);
 4794
 4795            // ── Шаг 2 выполнен → Шаг 3 ──
 4796            _setPushStep(2, 'done');
 4797            _setPushStep(3, 'active');
 4798            await new Promise(r => setTimeout(r, 350));
 4799            _setPushStep(3, 'done');
 4800            await new Promise(r => setTimeout(r, 500));
 4801
 4802            // ── Убираем overlay, показываем результат в обновлённом UI ──
 4803            _removePushOverlay();
 4804            _setPushResult(null, true, oldPrice, newPrice, null);
 4805
 4806        } catch (err) {
 4807            _removePushOverlay();
 4808            _setPushResult(uid, false, oldPrice, null, err.message);
 4809        }
 4810    });
 4811
 4812    // Обновляет ячейку цены в таблице отчёта после успешного SetProductPrice
 4813    // Обновляет цену строки отчёта в гриде после успешного SetProductPrice
 4814    function _updateTablePriceCell(article, clientId, newPrice, statusEl) {
 4815        const key      = `${article}_${clientId}`;
 4816        const info     = syncPriceInfoMap.get(key);
 4817        const oldPrice = info?.truePrice ?? null;
 4818
 4819        if (info) { info.truePrice = newPrice; syncPriceInfoMap.set(key, info); }
 4820        if (oldPrice != null) syncPriceUpdateMap.set(key, { oldPrice, newPrice });
 4821
 4822        window.BsxReportGrid?.patchPrice(article, clientId, oldPrice, newPrice);
 4823
 4824        if (statusEl) statusEl.textContent = `✓ ${newPrice.toLocaleString('ru-RU')} ₽ применена`;
 4825    }
 4826
 4827    // Матрица приоритетного наката (модалка «Логика наката») — справочный грид, статус К1 объединён по строкам.
 4828    const PRIORITY_BADGES = {
 4829        push: '<span class="badge bg-success">Накатить Q_1С</span>',
 4830        unarch: '<span class="badge bg-warning text-dark">Разархив + Q_1С</span>',
 4831        clear: '<span class="badge bg-danger">Clear → 0</span>',
 4832        skip: '<span class="badge bg-secondary">Пропустить</span>',
 4833        keep: '<span class="badge bg-secondary">Не трогать</span>'
 4834    };
 4835    const PRIORITY_K1 = {
 4836        active: '<span class="badge bg-primary">Активен</span>',
 4837        archived: '<span class="badge bg-warning text-dark">Архивный</span>',
 4838        notfound: '<span class="badge bg-danger">NotFound</span>'
 4839    };
 4840    const PRIORITY_ROWS = [
 4841        ['active', 'Активен, OzonQty > 0', 'push', 'clear'], ['active', 'Активен, OzonQty = 0', 'push', 'skip'],
 4842        ['active', 'Архивный', 'push', 'keep'], ['active', 'NotFound', 'push', 'skip'],
 4843        ['archived', 'Активен, OzonQty > 0', 'unarch', 'clear'], ['archived', 'Активен, OzonQty = 0', 'unarch', 'skip'],
 4844        ['archived', 'Архивный', 'unarch', 'keep'], ['archived', 'NotFound', 'unarch', 'skip'],
 4845        ['notfound', 'Активен', 'skip', 'push'], ['notfound', 'Архивный', 'skip', 'unarch'], ['notfound', 'NotFound', 's
 4846    ].map((r, i, all) => ({
 4847        id: String(i), k1: r[0], k2: r[1], a1: r[2], a2: r[3],
 4848        first: i === 0 || all[i - 1][0] !== r[0],
 4849        span: all.filter(x => x[0] === r[0]).length
 4850    }));
 4851    const priorityMatrixTable = window.BsxGrid?.localTable('syncPriorityMatrixTable', {
 4852        getRowId: p => p.data.id,
 4853        defaultColDef: { sortable: false, filter: false, floatingFilter: false, resizable: true },
 4854        gridOptions: {
 4855            domLayout: 'autoHeight', suppressRowTransform: true, rowHeight: 30,
 4856            getRowClass: p => 'bsx-prio-' + (p.data?.k1 || '')
 4857        },
 4858        columnDefs: [
 4859            { field: 'k1', headerName: 'К1', width: 100, minWidth: 100, rowSpan: p => p.data?.first ? p.data.span : 1,
 4860              cellClassRules: { 'bsx-span-cell': p => !!p.data?.first }, cellRenderer: p => p.data?.first ? PRIORITY_K1[
 4861            { field: 'k2', headerName: 'К2 статус', flex: 1.3, minWidth: 110, tooltipField: 'k2' },
 4862            { field: 'a1', headerName: 'Действие К1', flex: 1, minWidth: 115, cellRenderer: p => PRIORITY_BADGES[p.value
 4863            { field: 'a2', headerName: 'Действие К2', flex: 1, minWidth: 115, cellRenderer: p => PRIORITY_BADGES[p.value
 4864        ]
 4865    });
 4866    document.getElementById('syncPriorityInfoModal')?.addEventListener('shown.bs.modal', () => priorityMatrixTable?.setR
 4867
 4868    function mountBitrixSyncModals() {
 4869        [
 4870            'bitrixSyncCleanupPreviewModal',
 4871            'bitrixSyncDisabledMappingsModal',
 4872            'syncApplyWarningModal',
 4873            'syncApplyArchiveModal',
 4874            'syncPresetModal',
 4875            'syncPriorityInfoModal',
 4876            'syncApplyLogModal',
 4877            'syncOzonPricesModal',
 4878            'bitrixSyncExcelExportModal'
 4879        ].forEach(function (id) {
 4880            const el = document.getElementById(id);
 4881            if (el && el.parentElement !== document.body) {
 4882                document.body.appendChild(el);
 4883            }
 4884        });
 4885    }
 4886
 4887    // zoom-компенсация перенесена в sidebar.css (.modal/.modal-backdrop counter-zoom).
 4888    function initBitrixSyncReportModalViewport() {}
 4889
 4890    function initBitrixSyncReportPanelToggle() {
 4891        // Боковая панель удалена — фильтры в collapse-шапке. No-op.
 4892    }
 4893
 4894    const _stagesRowCollapsedKey = 'bsx.sync.stages.row.collapsed';
 4895    function _setStagesRowCollapsed(collapsed) {
 4896        const row = document.getElementById('bsxStagesRow');
 4897        const toggleBtn = document.getElementById('bsxStagesRowToggle');
 4898        const toggleIcon = document.getElementById('bsxStagesRowToggleIcon');
 4899        if (!row || !toggleBtn || !toggleIcon) return;
 4900
 4901        row.classList.toggle('is-collapsed', collapsed);
 4902        toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
 4903        toggleIcon.classList.toggle('fa-chevron-down', collapsed);
 4904        toggleIcon.classList.toggle('fa-chevron-up', !collapsed);
 4905    }
 4906
 4907    function initBitrixStagesRowToggle() {
 4908        const row = document.getElementById('bsxStagesRow');
 4909        const toggleBtn = document.getElementById('bsxStagesRowToggle');
 4910        if (!row || !toggleBtn) return;
 4911
 4912        const saved = localStorage.getItem(_stagesRowCollapsedKey);
 4913        const collapsed = saved == null ? true : saved === '1';
 4914        _setStagesRowCollapsed(collapsed);
 4915
 4916        toggleBtn.addEventListener('click', function () {
 4917            const nextCollapsed = !row.classList.contains('is-collapsed');
 4918            _setStagesRowCollapsed(nextCollapsed);
 4919            localStorage.setItem(_stagesRowCollapsedKey, nextCollapsed ? '1' : '0');
 4920        });
 4921    }
 4922
 4923    function initSyncOzonPricesBackdrop() {
 4924        const el = document.getElementById('syncOzonPricesModal');
 4925        if (!el) return;
 4926        el.addEventListener('show.bs.modal', function () {
 4927            if (!document.getElementById('bsxPriceBd')) {
 4928                const bd = document.createElement('div');
 4929                bd.id = 'bsxPriceBd';
 4930                document.body.appendChild(bd);
 4931            }
 4932        });
 4933        el.addEventListener('hidden.bs.modal', function () {
 4934            const bd = document.getElementById('bsxPriceBd');
 4935            if (bd) bd.remove();
 4936        });
 4937    }
 4938
 4939    function initSyncExcelExportModal() {
 4940        const el = document.getElementById('bitrixSyncExcelExportModal');
 4941        if (!el) return;
 4942        el.addEventListener('hidden.bs.modal', function () {
 4943            if (syncExcelExportPollTimer) {
 4944                clearInterval(syncExcelExportPollTimer);
 4945                syncExcelExportPollTimer = null;
 4946            }
 4947        });
 4948    }
 4949
 4950    // ─── Reserve map panel ────────────────────────────────────────────────────
 4951
 4952    function getSelectedStoresParam() {
 4953        const checked = [...document.querySelectorAll('.bitrix-sync-start-store-cb:checked')].map(el => el.value);
 4954        return checked.length > 0 ? checked.join(',') : '';
 4955    }
 4956
 4957    function getArticleParam() {
 4958        const v = (document.getElementById('bitrixSyncStartArticle')?.value || '').trim();
 4959        return v ? encodeURIComponent(v) : '';
 4960    }
 4961
 4962    function buildReserveMapApiUrl() {
 4963        const stores = getSelectedStoresParam();
 4964        const article = getArticleParam();
 4965        const statusIds = [...document.querySelectorAll('.bitrix-reserve-status-cb:checked')]
 4966            .map(el => el.value)
 4967            .filter(Boolean)
 4968            .join(',');
 4969        let url = orderReserveMapUrl;
 4970        const params = [];
 4971        if (stores) params.push('stores=' + encodeURIComponent(stores));
 4972        if (article) params.push('article=' + article);
 4973        if (statusIds) params.push('statusIds=' + encodeURIComponent(statusIds));
 4974        if (params.length) url += '?' + params.join('&');
 4975        return url;
 4976    }
 4977
 4978    function buildReserveMapExcelUrl() {
 4979        const stores = getSelectedStoresParam();
 4980        const article = getArticleParam();
 4981        const statusIds = [...document.querySelectorAll('.bitrix-reserve-status-cb:checked')]
 4982            .map(el => el.value)
 4983            .filter(Boolean)
 4984            .join(',');
 4985        let url = orderReserveMapExcelUrl;
 4986        const params = [];
 4987        if (stores) params.push('stores=' + encodeURIComponent(stores));
 4988        if (article) params.push('article=' + article);
 4989        if (statusIds) params.push('statusIds=' + encodeURIComponent(statusIds));
 4990        if (params.length) url += '?' + params.join('&');
 4991        return url;
 4992    }
 4993
 4994    // Карта резерва — грид AG Grid (сортировка, фильтры, поиск по артикулу/складу/заказам).
 4995    const reserveMapTable = window.BsxGrid?.localTable('bitrixReserveMapTable', {
 4996        key: 'reserve',
 4997        emptyText: 'Нет заказов в резерве по выбранным складам',
 4998        quickSearch: document.getElementById('bitrixReserveMapSearch'),
 4999        countEl: document.getElementById('bitrixReserveMapCount'),
 5000        getRowId: p => `${p.data.article}|${p.data.store}`,
 5001        gridOptions: { rowHeight: 40 },
 5002        columnDefs: [
 5003            { field: 'article', headerName: 'Артикул', width: 170, pinned: 'left', cellClass: 'fw-semibold' },
 5004            { field: 'store', headerName: 'Склад', width: 90, filter: window.BsxGrid?.SetFilter, floatingFilterComponent
 5005              cellRenderer: p => `<span class="badge bg-secondary">${escapeHtml(p.value)}</span>` },
 5006            { field: 'reservedQty', headerName: 'Резерв', width: 95, type: 'numericColumn', filter: 'agNumberColumnFilte
 5007              cellRenderer: p => `<span class="fw-bold text-danger">-${p.value}</span>` },
 5008            { field: 'orderCount', headerName: 'Заказов', width: 95, type: 'numericColumn', filter: 'agNumberColumnFilte
 5009            {
 5010                colId: 'orders', headerName: 'Заказы', flex: 1, minWidth: 260, sortable: false,
 5011                valueGetter: p => {
 5012                    const orders = Array.isArray(p.data?.orders) ? p.data.orders : [];
 5013                    return orders.length
 5014                        ? orders.map(o => `${o.shipmentNumber || o.orderNumber || ''} ${o.statusName || ''}`).join(' ')
 5015                        : (p.data?.orderNumbers || []).join(' ');
 5016                },
 5017                cellRenderer: p => {
 5018                    const orders = Array.isArray(p.data?.orders) ? p.data.orders : [];
 5019                    const fallback = Array.isArray(p.data?.orderNumbers) ? p.data.orderNumbers : [];
 5020                    if (orders.length) return orders.map(o => {
 5021                        const num = o.shipmentNumber
 5022                            ? `<span class="fw-semibold">${escapeHtml(o.shipmentNumber)}</span>`
 5023                            : `<span class="text-muted fst-italic">${escapeHtml(o.orderNumber || '')}</span>`;
 5024                        const date = o.processingDate ? ` <span class="opacity-75 small">${new Date(o.processingDate).to
 5025                        return `<span class="badge border me-1" style="background:${escapeHtml(o.statusColor || '#ffffff
 5026                    }).join('');
 5027                    return fallback.length
 5028                        ? fallback.map(n => `<span class="badge bg-light text-dark border me-1">${escapeHtml(n)}</span>`
 5029                        : '<span class="text-muted">—</span>';
 5030                }
 5031            }
 5032        ]
 5033    });
 5034
 5035    async function loadReserveMap() {
 5036        const summary = document.getElementById('bitrixReserveMapSummary');
 5037        if (!reserveMapTable) return;
 5038
 5039        const load = async () => {
 5040            reserveMapTable.loading(true);
 5041            if (summary) summary.textContent = '';
 5042            try {
 5043                const resp = await fetch(buildReserveMapApiUrl());
 5044                if (!resp.ok) throw new Error('HTTP ' + resp.status);
 5045                const data = await resp.json();
 5046                const lines = data.lines || [];
 5047                reserveMapTable.setRows(lines);
 5048                if (summary) summary.textContent = lines.length ? `Итого: ${data.totalArticles} арт., ${data.totalOrders
 5049                const excelLink = document.getElementById('bitrixReserveMapExcelLink');
 5050                if (excelLink) excelLink.href = buildReserveMapExcelUrl();
 5051            } catch (e) {
 5052                reserveMapTable.message('Ошибка загрузки: ' + e.message);
 5053            }
 5054        };
 5055
 5056        if (typeof PageLoadingBar !== 'undefined' && PageLoadingBar.run) {
 5057            await PageLoadingBar.run(load);
 5058        } else {
 5059            await load();
 5060        }
 5061    }
 5062
 5063    function initReserveMapPanel() {
 5064        const btn = document.getElementById('bitrixReserveMapBtn');
 5065        const panel = document.getElementById('bitrixReserveMapPanel');
 5066        const closeBtn = document.getElementById('bitrixReserveMapCloseBtn');
 5067        const refreshBtn = document.getElementById('bitrixReserveMapRefreshBtn');
 5068        if (!btn || !panel) return;
 5069
 5070        btn.addEventListener('click', function () {
 5071            const isHidden = panel.classList.contains('d-none');
 5072            if (isHidden) {
 5073                panel.classList.remove('d-none');
 5074                loadReserveMap();
 5075            } else {
 5076                panel.classList.add('d-none');
 5077            }
 5078        });
 5079        if (closeBtn) closeBtn.addEventListener('click', () => panel.classList.add('d-none'));
 5080        if (refreshBtn) refreshBtn.addEventListener('click', loadReserveMap);
 5081        document.querySelectorAll('.bitrix-reserve-status-cb').forEach(cb => {
 5082            cb.addEventListener('change', () => {
 5083                if (!panel.classList.contains('d-none')) void loadReserveMap();
 5084            });
 5085        });
 5086    }
 5087
 5088    // ─── Stage 5: Ozon Orphan Cleanup ─────────────────────────────────────────
 5089
 5090    let _orphanCurrentReportId = null;
 5091    let _orphanHubConn = null;
 5092    let _orphanTimer = null;
 5093    let _orphanSeconds = 0;
 5094
 5095    function initOrphanCleanupStage() {
 5096        const card = document.getElementById('bitrixSyncOrphanCard');
 5097        const previewBtn = document.getElementById('bitrixSyncOrphanPreviewBtn');
 5098        const startBtn = document.getElementById('bitrixSyncOrphanStartBtn');
 5099        if (!card || !previewBtn || !startBtn) return;
 5100
 5101        previewBtn.addEventListener('click', async () => {
 5102            if (!_orphanCurrentReportId) return;
 5103            previewBtn.disabled = true;
 5104            previewBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Загрузка…';
 5105            try {
 5106                const resp = await fetch(ozonOrphanPreviewUrl(_orphanCurrentReportId));
 5107                if (!resp.ok) throw new Error('HTTP ' + resp.status);
 5108                const data = await resp.json();
 5109                renderOrphanPreview(data.preview);
 5110            } catch (e) {
 5111                document.getElementById('bitrixSyncOrphanSummary').textContent = 'Ошибка загрузки предпросмотра: ' + e.m
 5112            } finally {
 5113                previewBtn.disabled = false;
 5114                previewBtn.innerHTML = '<i class="fas fa-eye me-1"></i>Предпросмотр';
 5115            }
 5116        });
 5117
 5118        startBtn.addEventListener('click', async () => {
 5119            if (!_orphanCurrentReportId || !(await St2Dialog.confirm('Обнулить все «сироты» на Ozon?', { variant: 'dange
 5120            startBtn.disabled = true;
 5121            previewBtn.disabled = true;
 5122
 5123            const jobId = _orphanCurrentReportId + '_s5_' + Date.now();
 5124            try {
 5125                const resp = await fetch(ozonOrphanStartUrl(_orphanCurrentReportId), {
 5126                    method: 'POST',
 5127                    headers: { 'Content-Type': 'application/json' },
 5128                    body: JSON.stringify({ reportId: _orphanCurrentReportId, jobId, dryRun: false })
 5129                });
 5130                if (!resp.ok) {
 5131                    const err = await resp.json().catch(() => ({ error: resp.statusText }));
 5132                    throw new Error(err.error || resp.statusText);
 5133                }
 5134                const res = await resp.json();
 5135                subscribeOrphanProgress(res.jobId || jobId);
 5136            } catch (e) {
 5137                alert('Ошибка запуска этапа 5: ' + e.message);
 5138                startBtn.disabled = false;
 5139                previewBtn.disabled = false;
 5140            }
 5141        });
 5142    }
 5143
 5144    // Превью этапа 5 — два грида: источники данных по складам и сами «сироты».
 5145    const orphanFilesTable = window.BsxGrid?.localTable('bitrixSyncOrphanFilesTable', {
 5146        key: 'orphanFiles',
 5147        gridOptions: { domLayout: 'autoHeight' },
 5148        defaultColDef: { floatingFilter: false },
 5149        columnDefs: [
 5150            { colId: 'warehouse', headerName: 'Склад', flex: 1, minWidth: 140,
 5151              valueGetter: p => p.data ? (p.data.bitrixWarehouseName || p.data.ozonWarehouseName || p.data.ozonWarehouse
 5152            { colId: 'source', headerName: 'Источник', width: 150, sortable: false, filter: false, cellRenderer: p => {
 5153                const f = p.data; if (!f) return '';
 5154                const dlUrl = f.fileUrl || (f.id ? ozonFileDownloadUrl(f.reportId, f.id) : null);
 5155                const m = (f.ozonReportCode || '').match(/^ozon_products_db_(\d+)_/);
 5156                if (dlUrl) return `<a href="${escapeHtml(dlUrl)}" target="_blank" class="btn btn-xs btn-outline-success"
 5157                if (!f.fileUrl && (f.ozonReportCode || '').startsWith('ozon_products_db_'))
 5158                    return `<span class="badge bg-info text-dark" title="Каталог OzonApi.ozon_products + остатки API${m 
 5159                return '<span class="text-muted">—</span>';
 5160            } },
 5161            { field: 'rowsWithStock', headerName: 'С остатком', width: 110, type: 'numericColumn' },
 5162            { field: 'orphansFound', headerName: 'Сирот', width: 90, type: 'numericColumn',
 5163              cellRenderer: p => p.value > 0 ? `<span class="text-danger fw-semibold">${p.value}</span>` : '<span class=
 5164            { field: 'orphansZeroed', headerName: 'Обнулено', width: 100, type: 'numericColumn',
 5165              cellRenderer: p => p.value > 0 ? `<span class="text-success fw-semibold">${p.value}</span>` : '<span class
 5166            { field: 'createdAtUtc', headerName: 'Загружен', width: 130, valueFormatter: p => window.BsxGrid.fmtUtc(p.va
 5167        ]
 5168    });
 5169
 5170    const orphanTable = window.BsxGrid?.localTable('bitrixSyncOrphanTable', {
 5171        key: 'orphans',
 5172        emptyText: 'Нет сирот (или этап ещё не запускался)',
 5173        columnDefs: [
 5174            { field: 'offerId', headerName: 'OfferId', width: 200, pinned: 'left', cellClass: 'text-danger fw-semibold' 
 5175            { field: 'bitrixWarehouseName', headerName: 'Склад 1С', width: 110, filter: window.BsxGrid?.SetFilter, float
 5176              cellRenderer: p => p.value ? `<span class="badge bg-secondary">${escapeHtml(p.value)}</span>` : '' },
 5177            { field: 'ozonWarehouseName', headerName: 'Склад Ozon', flex: 1, minWidth: 150, filter: window.BsxGrid?.SetF
 5178            { field: 'availableQty', headerName: 'Остаток', width: 100, type: 'numericColumn', filter: 'agNumberColumnFi
 5179            { colId: 'status', field: 'ozonOrphanStatus', headerName: 'Статус', width: 130, filter: window.BsxGrid?.SetF
 5180              cellRenderer: p => p.value ? `<span class="badge bg-success">${escapeHtml(p.value)}</span>` : '<span class
 5181        ]
 5182    });
 5183
 5184    function renderOrphanPreview(preview) {
 5185        const wrap = document.getElementById('bitrixSyncOrphanPreviewWrap');
 5186        const filesList = document.getElementById('bitrixSyncOrphanFilesList');
 5187        const filesTableEl = document.getElementById('bitrixSyncOrphanFilesTable');
 5188        const summary = document.getElementById('bitrixSyncOrphanSummary');
 5189        const startBtn = document.getElementById('bitrixSyncOrphanStartBtn');
 5190        const filesBtn = document.getElementById('bitrixSyncOrphanFilesBtn');
 5191        if (!wrap || !orphanTable) return;
 5192
 5193        wrap.classList.remove('d-none');
 5194
 5195        const files = preview.reportFiles || [];
 5196        if (files.length > 0) {
 5197            filesList.innerHTML = '<div class="fw-semibold text-muted small"><i class="fas fa-database me-1"></i>Источни
 5198            if (filesTableEl) filesTableEl.hidden = false;
 5199            orphanFilesTable?.setRows(files);
 5200
 5201            // Кнопка скачивания в заголовке карточки (первый файл = первый склад, тот же xlsx)
 5202            if (filesBtn) {
 5203                const firstFile = files[0];
 5204                const dlUrl = firstFile.fileUrl || (firstFile.id ? ozonFileDownloadUrl(firstFile.reportId, firstFile.id)
 5205                if (dlUrl) {
 5206                    filesBtn.href = dlUrl;
 5207                    filesBtn.classList.remove('d-none');
 5208                }
 5209            }
 5210        } else {
 5211            filesList.innerHTML = '<span class="text-muted small">Данные этапа 5 ещё не загружены — запустите этап.</spa
 5212            if (filesTableEl) filesTableEl.hidden = true;
 5213            if (filesBtn) filesBtn.classList.add('d-none');
 5214        }
 5215
 5216        const orphans = preview.orphans || [];
 5217        orphanTable.setRows(orphans);
 5218        if (orphans.length > 0) {
 5219            summary.textContent = `Найдено ${preview.totalOrphans} сирот.`;
 5220            if (!preview.isAlreadyRun) startBtn.disabled = false;
 5221        } else {
 5222            summary.textContent = '';
 5223        }
 5224    }
 5225
 5226    function subscribeOrphanProgress(jobId) {
 5227        const progressWrap = document.getElementById('bitrixSyncOrphanProgressWrap');
 5228        const progressBar = document.getElementById('bitrixSyncOrphanProgressBar');
 5229        const progressText = document.getElementById('bitrixSyncOrphanProgressText');
 5230        const logDiv = document.getElementById('bitrixSyncOrphanLog');
 5231        const elapsed = document.getElementById('bitrixSyncOrphanElapsed');
 5232        if (!progressWrap) return;
 5233
 5234        progressWrap.classList.remove('d-none');
 5235        _activeJobStage = 5; _activeJobReportId = _orphanCurrentReportId;
 5236        const _stage5Src = document.getElementById('syncPresetStage5CatalogSource')?.value || 'OzonProductsDb';
 5237        const _stage5Label = _stage5Src === 'WarehouseStockReport'
 5238            ? 'Ozon xlsx warehouse/stock (legacy)'
 5239            : 'OzonApi.ozon_products + остатки Ozon API';
 5240        appendOrphanLog(`Этап 5: источник каталога — ${_stage5Label} (${_stage5Src})`);
 5241        _plog(5, 'OrphanManualStart', { jobId, catalogSource: _stage5Src, label: _stage5Label });
 5242        document.getElementById('presetAutoRunStageText').textContent = 'Этап 5 — очистка сирот Ozon';
 5243        if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none'))
 5244            _par.setStep(5, 'active');
 5245        _orphanSeconds = 0;
 5246        _orphanTimer = setInterval(() => {
 5247            _orphanSeconds++;
 5248            const m = String(Math.floor(_orphanSeconds / 60)).padStart(2, '0');
 5249            const s = String(_orphanSeconds % 60).padStart(2, '0');
 5250            if (elapsed) elapsed.textContent = `${m}:${s}`;
 5251        }, 1000);
 5252
 5253        if (_orphanHubConn) { try { _orphanHubConn.stop(); } catch {} }
 5254
 5255        const conn = new signalR.HubConnectionBuilder()
 5256            .withUrl('/jobProgressHub')
 5257            .withAutomaticReconnect()
 5258            .build();
 5259
 5260        conn.on('Log', msg => appendOrphanLog(msg));
 5261
 5262        conn.on('Progress', (cur, total, text) => {
 5263            if (!progressBar || !progressText) return;
 5264            const pct = total > 0 ? Math.round(cur * 100 / total) : 0;
 5265            progressBar.style.width = pct + '%';
 5266            progressBar.textContent = pct + '%';
 5267            progressText.textContent = text || '';
 5268        });
 5269
 5270        conn.on('Complete', (ok, payload) => {
 5271            clearInterval(_orphanTimer);
 5272            _activeJobStage = 0;
 5273            progressBar.classList.remove('progress-bar-animated');
 5274            progressBar.style.width = '100%';
 5275            progressText.textContent = ok ? 'Завершено' : 'Ошибка';
 5276            if (!document.getElementById('presetAutoRunPanel')?.classList.contains('d-none')) {
 5277                _par.setStep(5, ok ? 'done' : 'error');
 5278                _par.finish(!ok);
 5279            }
 5280            if (!ok && payload) appendOrphanLog('Ошибка: ' + String(payload));
 5281            else if (ok) appendOrphanLog('Этап 5 завершён' + (payload ? ': ' + JSON.stringify(payload) : ''));
 5282            document.getElementById('bitrixSyncOrphanPreviewBtn')?.click();
 5283            document.getElementById('bitrixSyncOrphanStartBtn').disabled = true;
 5284            document.getElementById('bitrixSyncOrphanPreviewBtn').disabled = false;
 5285        });
 5286
 5287        conn.start()
 5288            .then(() => conn.invoke('JoinJobGroup', jobId))
 5289            .catch(e => console.error('[OrphanStage5] SignalR error:', e));
 5290
 5291        _orphanHubConn = conn;
 5292    }
 5293
 5294    // Вызывается при загрузке/смене отчёта — показываем/скрываем кнопку Stage 5
 5295    function onOrphanReportLoaded(reportId) {
 5296        _orphanCurrentReportId = reportId;
 5297        const card = document.getElementById('bitrixSyncOrphanCard');
 5298        if (card) card.style.display = reportId ? '' : 'none';
 5299        const filesBtn = document.getElementById('bitrixSyncOrphanFilesBtn');
 5300        if (filesBtn) filesBtn.classList.add('d-none');
 5301
 5302        // Если были сохранены файлы Ozon — показать предпросмотр сразу
 5303        if (reportId) {
 5304            fetch(ozonFilesUrl(reportId))
 5305                .then(r => r.json())
 5306                .then(d => {
 5307                    if (d.files && d.files.length > 0) {
 5308                        // Автоматически обновляем предпросмотр
 5309                        document.getElementById('bitrixSyncOrphanPreviewBtn')?.click();
 5310                    }
 5311                })
 5312                .catch(() => {});
 5313        }
 5314    }
 5315
 5316    if (document.readyState === 'loading') {
 5317        document.addEventListener('DOMContentLoaded', function () {
 5318            mountBitrixSyncModals();
 5319            initBitrixSyncReportModalViewport();
 5320            initBitrixSyncReportPanelToggle();
 5321            initBitrixStagesRowToggle();
 5322            initSyncOzonPricesBackdrop();
 5323            initSyncExcelExportModal();
 5324            initReserveMapPanel();
 5325            initOrphanCleanupStage();
 5326        });
 5327    } else {
 5328        mountBitrixSyncModals();
 5329        initBitrixSyncReportModalViewport();
 5330        initBitrixSyncReportPanelToggle();
 5331        initBitrixStagesRowToggle();
 5332        initSyncOzonPricesBackdrop();
 5333        initSyncExcelExportModal();
 5334        initReserveMapPanel();
 5335        initOrphanCleanupStage();
 5336    }
 5337})();
 5338</script>

Methods/Properties

ExecuteAsync()