// web-test spreadsheet v1.20 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; import { getFormState } from '../forms/state.mjs'; import { returnFormState } from '../core/helpers.mjs'; import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs'; import { checkForErrors } from '../core/errors.mjs'; // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- /** * Scan spreadsheet iframes for the current form and collect all cells. * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> } * where frameIndex is the Playwright frames[] index (1-based, 0 = main). */ async function scanSpreadsheetCells(formNum) { const prefix = `form${formNum ?? 0}_`; const iframeHandles = await page.$$('iframe'); const allCells = new Map(); const frameMap = new Map(); // key 'r_c' → Playwright Frame object for (const handle of iframeHandles) { const ok = await handle.evaluate((f, pfx) => { if (f.offsetWidth < 100) return false; let el = f.parentElement; for (let d = 0; el && d < 30; d++, el = el.parentElement) { if (el.id && el.id.startsWith(pfx)) return true; } return false; }, prefix); if (!ok) continue; const frame = await handle.contentFrame(); if (!frame) continue; try { const cells = await frame.evaluate(`(() => { const cells = []; document.querySelectorAll('div[x]').forEach(d => { const span = d.querySelector('span'); const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || ''; if (!text) return; const rowDiv = d.parentElement; const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null; const col = d.getAttribute('x'); if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text }); }); return cells; })()`); for (const cell of cells) { const key = `${cell.r}_${cell.c}`; if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) { allCells.set(key, cell); frameMap.set(key, frame); } } } catch { /* skip inaccessible frames */ } } return { allCells, frameMap }; } /** * Build structured mapping from raw cells: headers, column map, data/totals row indices. * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap } * or null if header detection fails. */ function buildSpreadsheetMapping(allCells) { const rowMap = new Map(); let maxCol = 0; for (const cell of allCells.values()) { if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); rowMap.get(cell.r).set(cell.c, cell.t); if (cell.c > maxCol) maxCol = cell.c; } const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); const rows = sortedRows.map(r => { const cm = rowMap.get(r); const arr = []; for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); return arr; }); // Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78" // Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification. const isNumericVal = (c) => { if (!c || !/\d/.test(c)) return false; const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, ''); return /^\d[\d,]*$/.test(s); }; // Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus). // Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51". const isDataNumericVal = (c) => { if (!isNumericVal(c)) return false; return /[\s\u00a0,]/.test(c) || /^-/.test(c); }; const hasNumber = (row) => row.some(c => isNumericVal(c)); const nonEmpty = (row) => row.filter(c => c !== '').length; // Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx. // Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor). const buildRichMapping = (detailIdx, firstDataIdx) => { let groupIdx = -1; if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1; const detailRow = rows[detailIdx]; const groupRow = groupIdx >= 0 ? rows[groupIdx] : null; // Detect optional third header level above group row (bounds carry-forward) let superRow = null; if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) { superRow = rows[groupIdx - 1]; } // Build column names (group + detail merge) const groupFilled = new Array(maxCol + 1).fill(''); if (groupRow) { let cur = ''; for (let c = 0; c <= maxCol; c++) { if (groupRow[c]) { cur = groupRow[c]; } else if (superRow && superRow[c]) { // New top-level header starts here — stop carry-forward cur = ''; } groupFilled[c] = cur; } } const detailCounts = {}; for (let c = 0; c <= maxCol; c++) { const n = detailRow[c]; if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; } // Detect DCS column codes (К1, К2, ...) — always prefix with group when present const detailNonEmpty = detailRow.filter(c => c); const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c)); const colNames = []; for (let c = 0; c <= maxCol; c++) { const detail = detailRow[c]; const group = groupFilled[c]; const sup = superRow ? superRow[c] : ''; if (detail) { // Prefer group prefix; fall back to superRow for DCS code columns without sub-group const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : ''); const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); colNames.push(needPrefix ? `${prefix} / ${detail}` : detail); } else if (group) { colNames.push(group); } else if (sup) { colNames.push(sup); } else { colNames.push(null); } } const colMap = new Map(); for (let c = 0; c < colNames.length; c++) { if (colNames[c]) colMap.set(colNames[c], c); } // Classify data rows: separate data indices and totals index const dataRowIndices = []; let totalsRowIdx = -1; for (let i = firstDataIdx; i < rows.length; i++) { if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; const first = rows[i][0]?.trim().toLowerCase(); if (first === 'итого' || first === 'всего') { totalsRowIdx = i; } else { dataRowIndices.push(i); } } const superRowIdx = superRow ? groupIdx - 1 : -1; return { rows, sortedRows, maxCol, colNames, colMap, headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx, dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx, rowMap, hasNumber, nonEmpty, }; }; // --- Level 1: DCS-code row anchor --- // ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker. // Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб"). for (let i = 0; i < rows.length; i++) { const detailNonEmpty = rows[i].filter(c => c); if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) { // Find first non-empty row after the К-codes row as data start let firstDataIdx = rows.length; for (let j = i + 1; j < rows.length; j++) { if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; } } return buildRichMapping(i, firstDataIdx); } } // --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) --- let firstDataIdx = rows.length; for (let i = 0; i < rows.length; i++) { if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; } } if (firstDataIdx === rows.length) { for (let i = 0; i < rows.length; i++) { if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; } } } if (firstDataIdx < rows.length) { let detailIdx = -1; for (let i = firstDataIdx - 1; i >= 0; i--) { if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; } } if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx); } // --- Level 3: single-row header fallback (text-only data, query console) --- // First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition. let headerIdx = -1; for (let i = 0; i < rows.length; i++) { if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; } } // Single-column tables: accept nonEmpty >= 1 if (headerIdx === -1 && maxCol === 0) { for (let i = 0; i < rows.length; i++) { if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; } } } if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total } const detailRow = rows[headerIdx]; const colNames = []; for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null); const colMap = new Map(); for (let c = 0; c < colNames.length; c++) { if (colNames[c]) colMap.set(colNames[c], c); } const dataRowIndices = []; let totalsRowIdx = -1; for (let i = headerIdx + 1; i < rows.length; i++) { if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; const first = rows[i][0]?.trim().toLowerCase(); if (first === 'итого' || first === 'всего') { totalsRowIdx = i; } else { dataRowIndices.push(i); } } return { rows, sortedRows, maxCol, colNames, colMap, headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1, dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx, rowMap, hasNumber, nonEmpty, }; } /** * Scroll SpreadsheetDocument to make a cell visible using arrow keys. * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. * * How it works: * 1. Check target cell visibility via Playwright boundingBox (page-level coords). * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. * This is the same native click that clickSpreadsheetCell uses — it gives keyboard * focus to the spreadsheet and keeps headers/data/scrollbar in sync. * (frame.locator().click() bypasses overlay → desyncs frozen headers; * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. * * @param {Frame} frame - Playwright Frame containing the spreadsheet cells * @param {number} physRow - physical row (y attribute) in the frame * @param {number} physCol - physical column (x attribute) in the frame * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) */ async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { const pageVw = await page.evaluate('window.innerWidth'); // Get iframe bounds — the actual visible region on page. // The iframe may extend behind the section panel on the left, so cells with // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. const frameElm = await frame.frameElement(); const frameBox = await frameElm.boundingBox(); const visLeft = frameBox ? frameBox.x : 0; const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; const getBox = async () => { try { return await cellLoc.boundingBox({ timeout: 500 }); } catch { return null; } }; const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; let box = await getBox(); if (!box) return; // cell not in DOM if (isFullyVisible(box)) return; const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; // Find a fully-visible cell to click for focus. // Prefer cells in the target row (scrollable area), fall back to any row. const targetRowSel = `div[y="${physRow}"] div[x]`; const anyRowSel = 'div[x]'; let focusClicked = false; for (const sel of [targetRowSel, anyRowSel]) { const locs = frame.locator(sel); const count = await locs.count(); const candidates = []; for (let ci = 0; ci < count; ci++) { const b = await locs.nth(ci).boundingBox(); if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { candidates.push({ ci, box: b }); } } if (candidates.length === 0) continue; candidates.sort((a, b) => a.box.x - b.box.x); // ArrowRight → rightmost fully-visible (each press scrolls right immediately) // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) const pick = direction === 'ArrowRight' ? candidates[candidates.length - 1] : candidates[0]; // Native click through overlay — gives keyboard focus + no header desync. await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); await page.waitForTimeout(100); focusClicked = true; break; } if (!focusClicked) return; // no visible cells — can't scroll await scrollHorizontallyByKey({ page, direction, isFullyVisible: async () => { const b = await getBox(); return !!b && isFullyVisible(b); }, getCenterX: async () => { const b = await getBox(); return b ? b.x + b.width / 2 : null; }, }); } /** * Click a cell in SpreadsheetDocument by logical coordinates. * target: { row: number|'totals'|{colName: value}, column: string } * Internal helper — called from clickElement when first arg is an object. */ export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { ensureConnected(); const formNum = await page.evaluate(detectFormScript()); const { allCells, frameMap } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.'); const mapping = buildSpreadsheetMapping(allCells); if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.'); const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping; // Resolve column (exact → endsWith " / X" → includes) let colName = target.column; if (!colMap.has(colName)) { const available = colNames.filter(n => n); const suffix = ' / ' + colName; const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName)); if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`); colName = match; } const physCol = colMap.get(colName); // Resolve row → index into rows[] array let rowIdx; const row = target.row; if (row === 'totals') { if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.'); rowIdx = totalsRowIdx; } else if (typeof row === 'number') { if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`); rowIdx = dataRowIndices[row]; } else if (typeof row === 'object') { // Filter: { colName: value } — find first data row where column matches const filterEntries = Object.entries(row); const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; const resolveCol = (name) => { if (colMap.has(name)) return colMap.get(name); const suffix = ' / ' + name; const available = colNames.filter(n => n); const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name)); return m ? colMap.get(m) : null; }; rowIdx = dataRowIndices.find(i => { return filterEntries.every(([fCol, fVal]) => { const fColIdx = resolveCol(fCol); if (fColIdx == null) return false; const cellText = norm(rows[i][fColIdx]); const search = norm(fVal); return cellText === search || cellText.includes(search); }); }); if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`); } else { throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.'); } // Map rows[] index → physical row number const physRow = sortedRows[rowIdx]; const cellKey = `${physRow}_${physCol}`; const frame = frameMap.get(cellKey); if (!frame) { // Cell exists in mapping but might be empty — try clicking anyway throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`); } // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); // Scroll cell into view using arrow keys — the only reliable way to scroll // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); const box = await cellDiv.boundingBox(); if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); const x = box.x + box.width / 2; const y = box.y + box.height / 2; const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; if (modKey) await page.keyboard.down(modKey); if (dbl) { await page.mouse.dblclick(x, y); } else { await page.mouse.click(x, y); } if (modKey) await page.keyboard.up(modKey); await waitForStable(); return returnFormState({ clicked: { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) } }); } /** * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). * Returns { frameIndex, physRow, physCol, box } or null if not found. */ export async function findSpreadsheetCellByText(formNum, searchText) { const { allCells, frameMap } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) return null; const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; const target = norm(searchText); // Exact match first, then includes let found = null; for (const [key, cell] of allCells) { if (norm(cell.t) === target) { found = { key, cell }; break; } } if (!found) { for (const [key, cell] of allCells) { if (norm(cell.t).includes(target)) { found = { key, cell }; break; } } } if (!found) return null; const frame = frameMap.get(found.key); if (!frame) return null; // Scroll cell into view using native arrow-key mechanism const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); const box = await cellDiv.boundingBox(); if (!box) return null; return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box }; } /** * Read report output (SpreadsheetDocumentField) rendered in iframes. * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes. * Each cell is a div[x] inside a row div[y], text content in . * * Returns structured data: * { title, headers, data: [{col: val}], totals: {col: val}, total } * If header detection fails, falls back to { rows: string[][], total }. */ export async function readSpreadsheet() { ensureConnected(); const formNum = await page.evaluate(detectFormScript()); const { allCells } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) { // Check for state window messages (info bar) that explain why the report is empty const err = await checkForErrors(); const hint = err?.stateText?.length ? err.stateText.join('; ') : ''; throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.')); } const mapping = buildSpreadsheetMapping(allCells); if (!mapping) { // Fallback: return raw rows const rowMap = new Map(); let maxCol = 0; for (const cell of allCells.values()) { if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); rowMap.get(cell.r).set(cell.c, cell.t); if (cell.c > maxCol) maxCol = cell.c; } const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); const rows = sortedRows.map(r => { const cm = rowMap.get(r); const arr = []; for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); return arr; }); return { rows, total: rows.length }; } const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping; // Convert data rows to objects const data = []; let totals = null; const toObj = (row) => { const obj = {}; for (let c = 0; c < colNames.length; c++) { if (colNames[c] && row[c]) obj[colNames[c]] = row[c]; } return obj; }; for (let i = dataStartIdx; i < rows.length; i++) { if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; const first = rows[i][0]?.trim().toLowerCase(); if (first === 'итого' || first === 'всего') { totals = toObj(rows[i]); } else { data.push(toObj(rows[i])); } } // Meta: title, params, filters from rows before header (superRow is part of header, not meta) const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx); let title = ''; const meta = []; for (let i = 0; i < metaEnd; i++) { const parts = rows[i].filter(c => c); if (!parts.length) continue; if (!title) { title = parts.join(' '); continue; } meta.push(parts.join(' ')); } return { title: title || undefined, meta: meta.length ? meta : undefined, headers: colNames.filter(n => n), data, totals: totals || undefined, total: data.length, }; }