| | | 1 | | @{ |
| | 0 | 2 | | var bitrixStockBaseUrl = Url.Action("Index", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 3 | | var syncStartUrl = Url.Action("StartSyncReport", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 4 | | var syncReportsListUrl = Url.Action("ListSyncReports", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 5 | | var syncReportGetUrlTemplate = Url.Action("GetSyncReport", "BitrixStock", new { area = "Studio2", reportId = "__REPO |
| | 0 | 6 | | var syncReportOneCStoresUrlTemplate = Url.Action("GetSyncReportOneCStores", "BitrixStock", new { area = "Studio2", r |
| | 0 | 7 | | var syncReportApplyStartUrlTemplate = Url.Action("StartSyncReportApply", "BitrixStock", new { area = "Studio2", repo |
| | 0 | 8 | | var syncReportExcelUrlTemplate = Url.Action("ExportSyncReportExcel", "BitrixStock", new { area = "Studio2", reportId |
| | 0 | 9 | | var syncReportExcelTaskStartUrlTemplate = Url.Action("StartSyncReportExcelExportTask", "BitrixStock", new { area = " |
| | 0 | 10 | | var syncReportExcelTaskStatusUrlTemplate = Url.Action("GetSyncReportExcelExportTaskStatus", "BitrixStock", new { are |
| | 0 | 11 | | var syncReportExcelTaskDownloadUrlTemplate = Url.Action("DownloadSyncReportExcelExportTaskResult", "BitrixStock", ne |
| | 0 | 12 | | var syncReportProductNamesUrlTemplate = Url.Action("GetSyncReportProductNames", "BitrixStock", new { area = "Studio2 |
| | 0 | 13 | | var syncReportSendEmailUrlTemplate = Url.Action("SendSyncReportEmail", "BitrixStock", new { area = "Studio2", report |
| | 0 | 14 | | var warehouseMappingsUrlTemplate = Url.Action("GetWarehouseMappingsForClient", "BitrixStock", new { area = "Studio2" |
| | 0 | 15 | | var syncPresetsUrl = Url.Action("GetSyncPresets", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 16 | | var syncPresetsCreateUrl = Url.Action("CreateSyncPreset", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 17 | | var syncPresetsBaseUrl = Url.Action("GetSyncPresets", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 18 | | var syncReportUnarchiveUrlTemplate = Url.Action("UnarchiveReportProducts", "BitrixStock", new { area = "Studio2", re |
| | 0 | 19 | | var syncReportArchiveUrlTemplate = Url.Action("ArchiveReportProducts", "BitrixStock", new { area = "Studio2", report |
| | 0 | 20 | | var syncApplyLogUrlTemplate = Url.Action("GetApplyArticleLog", "BitrixStock", new { area = "Studio2", reportId = "__ |
| | 0 | 21 | | var syncApplyLogJobsUrlTemplate = Url.Action("GetApplyLogJobs", "BitrixStock", new { area = "Studio2", reportId = "_ |
| | 0 | 22 | | var syncArchivePreviewUrlTemplate = Url.Action("GetArchivedPreview", "BitrixStock", new { area = "Studio2", reportId |
| | 0 | 23 | | var syncReportLogUrlTemplate = Url.Action("DownloadSyncReportLog", "BitrixStock", new { area = "Studio2", reportId = |
| | 0 | 24 | | var syncReportMappingCombosUrlTemplate = Url.Action("GetSyncReportMappingCombos", "BitrixStock", new { area = "Studi |
| | 0 | 25 | | var syncReportCleanupPreviewUrlTemplate = Url.Action("GetCleanupPreview", "BitrixStock", new { area = "Studio2", rep |
| | 0 | 26 | | var syncReportCleanupStartUrlTemplate = Url.Action("StartCleanup", "BitrixStock", new { area = "Studio2", reportId = |
| | 0 | 27 | | var syncReportPricePushStartUrlTemplate = Url.Action("StartSyncReportPricePush", "BitrixStock", new { area = "Studio |
| | 0 | 28 | | var syncReportCleanupZeroUrlTemplate = Url.Action("ZeroCleanupWarehouse", "BitrixStock", new { area = "Studio2", rep |
| | 0 | 29 | | var syncReportPromosUrlTemplate = Url.Action("GetSyncReportPromos", "BitrixStock", new { area = "Studio2", reportId |
| | 0 | 30 | | var syncReportPriceSettingsUrlTemplate = Url.Action("GetSyncReportPriceSettings", "BitrixStock", new { area = "Studi |
| | 0 | 31 | | var articleC1PriceUrlTemplate = Url.Action("GetArticleC1Price", "BitrixStock", new { area = "Studio2", article = "__ |
| | 0 | 32 | | var priceOptimizationUrlTemplate = Url.Action("GetPriceOptimization", "BitrixStock", new { area = "Studio2", reportI |
| | 0 | 33 | | var orderReserveMapUrl = Url.Action("GetOrderReserveMap", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 34 | | var orderReserveMapExcelUrl = Url.Action("ExportOrderReserveMapExcel", "BitrixStock", new { area = "Studio2" })!; |
| | 0 | 35 | | var ozonOrphanPreviewUrlTemplate = Url.Action("GetOzonOrphanPreview", "BitrixStock", new { area = "Studio2", reportI |
| | 0 | 36 | | var ozonOrphanStartUrlTemplate = Url.Action("StartOzonOrphanCleanup", "BitrixStock", new { area = "Studio2", reportI |
| | 0 | 37 | | var ozonFilesUrlTemplate = Url.Action("GetOzonReportFiles", "BitrixStock", new { area = "Studio2", reportId = "__REP |
| | 0 | 38 | | var ozonFileDownloadUrlTemplate = Url.Action("DownloadOzonReportFile", "BitrixStock", new { area = "Studio2", report |
| | | 39 | | } |
| | | 40 | | <script> |
| | | 41 | | (function () { |
| | 0 | 42 | | 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 | | |
| | 0 | 112 | | const syncStartUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncStartUrl))); |
| | 0 | 113 | | const syncReportsListUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncReport |
| | 0 | 114 | | const syncReportGetUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(sync |
| | 0 | 115 | | const syncReportOneCStoresUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seriali |
| | 0 | 116 | | const syncReportApplyStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seriali |
| | 0 | 117 | | const syncReportExcelUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(sy |
| | 0 | 118 | | const syncReportExcelTaskStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Ser |
| | 0 | 119 | | const syncReportExcelTaskStatusUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Se |
| | 0 | 120 | | const syncReportExcelTaskDownloadUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer. |
| | 0 | 121 | | const syncReportProductNamesUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seria |
| | 0 | 122 | | const syncReportSendEmailUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serializ |
| | 0 | 123 | | const warehouseMappingsUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize( |
| | 0 | 124 | | const syncPresetsUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncPresetsUrl |
| | 0 | 125 | | const syncPresetsCreateUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncPres |
| | 0 | 126 | | const syncPresetsBaseUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncPreset |
| | 0 | 127 | | const syncReportUnarchiveUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serializ |
| | 0 | 128 | | const syncReportArchiveUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize( |
| | 0 | 129 | | const syncApplyLogUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(syncA |
| | 0 | 130 | | const syncApplyLogJobsUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(s |
| | 0 | 131 | | const syncArchivePreviewUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize |
| | 0 | 132 | | const syncReportLogUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(sync |
| | 0 | 133 | | const syncReportMappingCombosUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seri |
| | 0 | 134 | | const syncReportCleanupPreviewUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Ser |
| | 0 | 135 | | const syncReportCleanupStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seria |
| | 0 | 136 | | const syncReportPricePushStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Ser |
| | 0 | 137 | | const syncReportCleanupZeroUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serial |
| | 0 | 138 | | const syncReportPromosUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(s |
| | 0 | 139 | | const syncReportPriceSettingsUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Seri |
| | 0 | 140 | | const articleC1PriceUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(art |
| | 0 | 141 | | const priceOptimizationUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize( |
| | 0 | 142 | | const orderReserveMapUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(orderReser |
| | 0 | 143 | | const orderReserveMapExcelUrl = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(order |
| | 0 | 144 | | const ozonOrphanPreviewUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize( |
| | 0 | 145 | | const ozonOrphanStartUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(oz |
| | 0 | 146 | | const ozonFilesUrlTemplate = normalizeBitrixStockApiUrl(@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ozonFile |
| | 0 | 147 | | 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 Партия |
| | | 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 = `Недостаточно для упаковки 1С: ${qty1c} шт. < ${mult} шт./уп. → 0 уп. Базовый: ${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 управление остатками 1С: ${qty1c} шт. | ×${mult} ${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, '&') |
| | | 2430 | | .replace(/"/g, '"') |
| | | 2431 | | .replace(/</g, '<'); |
| | | 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>> 20 ₽</strong></li> |
| | | 4365 | | <li>Цена 400–10 000 ₽ — разница должна быть <strong>> 5%</strong></li> |
| | | 4366 | | <li>Цена > 10 000 ₽ — разница должна быть <strong>> 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> |