fix(web-test): scope readSpreadsheet to current form, improve headers

Two fixes:
- Scope iframe scanning to current form container (prevents reading
  stale data from other open report tabs)
- Improved header detection: use LAST row before data as detail header,
  previous row as group header with fill-forward for merged cells.
  Fixes two-level headers like "Начальный остаток / Долг клиента"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-02-28 16:34:42 +03:00
co-authored by Claude Opus 4.6
parent 366378c4b5
commit 5d18bf4501
+68 -29
View File
@@ -438,12 +438,35 @@ export async function readTable({ maxRows = 20, offset = 0 } = {}) {
*/ */
export async function readSpreadsheet() { export async function readSpreadsheet() {
ensureConnected(); ensureConnected();
const formNum = await page.evaluate(detectFormScript());
// Collect iframe indices that belong to the current form's spreadsheet container
const iframeIndices = await page.evaluate(`(() => {
const prefix = 'form${formNum ?? 0}_';
const allIframes = [...document.querySelectorAll('iframe')];
const indices = [];
for (let i = 0; i < allIframes.length; i++) {
const f = allIframes[i];
if (f.offsetWidth < 100) continue;
let el = f.parentElement, found = false;
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
if (el.id && el.id.startsWith(prefix)) { found = true; break; }
}
if (found) indices.push(i);
}
return indices;
})()`);
const frames = page.frames(); const frames = page.frames();
const allCells = new Map(); const allCells = new Map();
for (let fi = 1; fi < frames.length; fi++) { // Map page iframe indices to frame objects (frame 0 = main, iframes start at 1+)
for (const iframeIdx of iframeIndices) {
// Playwright frames: frame[0] is main, frame[1..N] map to iframes in DOM order
const frame = frames[iframeIdx + 1];
if (!frame) continue;
try { try {
const cells = await frames[fi].evaluate(`(() => { const cells = await frame.evaluate(`(() => {
const cells = []; const cells = [];
document.querySelectorAll('div[x]').forEach(d => { document.querySelectorAll('div[x]').forEach(d => {
const span = d.querySelector('span'); const span = d.querySelector('span');
@@ -488,46 +511,62 @@ export async function readSpreadsheet() {
const hasNumber = (row) => row.some(c => /^[\d\s\u00a0]/.test(c) && /\d/.test(c)); const hasNumber = (row) => row.some(c => /^[\d\s\u00a0]/.test(c) && /\d/.test(c));
const nonEmpty = (row) => row.filter(c => c !== '').length; const nonEmpty = (row) => row.filter(c => c !== '').length;
// 1. Find header row: row with most non-empty cells BEFORE first row with numbers // 1. Find first data row (first row with numbers)
let headerIdx = -1; let firstDataIdx = rows.length;
let bestCount = 0;
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
if (hasNumber(rows[i])) break; if (hasNumber(rows[i])) { firstDataIdx = i; break; }
const cnt = nonEmpty(rows[i]);
if (cnt >= bestCount) { bestCount = cnt; headerIdx = i; }
} }
if (headerIdx === -1 || bestCount < 2) return { rows, total: rows.length }; // 2. Find header rows: scan backwards from data, pick last row with ≥3 cells as detail header
let detailIdx = -1;
for (let i = firstDataIdx - 1; i >= 0; i--) {
if (nonEmpty(rows[i]) >= 3) { detailIdx = i; break; }
}
if (detailIdx === -1) return { rows, total: rows.length };
const headerRow = rows[headerIdx]; // Group header: row before detail with ≥2 non-empty cells
let groupIdx = -1;
if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;
// 2. Check for group header row (row just before header, sparse text entries) const detailRow = rows[detailIdx];
let groupRow = null; const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;
if (headerIdx > 0) {
const prev = rows[headerIdx - 1]; // 3. Build column names by merging group + detail rows
const prevCnt = nonEmpty(prev); // Fill-forward group names across empty columns (merged cells)
if (prevCnt > 0 && prevCnt < bestCount) groupRow = prev; const groupFilled = new Array(maxCol + 1).fill('');
if (groupRow) {
let cur = '';
for (let c = 0; c <= maxCol; c++) {
if (groupRow[c]) cur = groupRow[c];
groupFilled[c] = cur;
}
} }
// 3. Build column names; disambiguate duplicates with group prefix // For each column: use detail name if available, else group name
const nameCounts = {}; // Prefix with group when duplicates exist in detail row
const detailCounts = {};
for (let c = 0; c <= maxCol; c++) { for (let c = 0; c <= maxCol; c++) {
const n = headerRow[c]; const n = detailRow[c];
if (n) nameCounts[n] = (nameCounts[n] || 0) + 1; if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
} }
const colNames = []; const colNames = [];
let curGroup = '';
for (let c = 0; c <= maxCol; c++) { for (let c = 0; c <= maxCol; c++) {
if (groupRow && groupRow[c]) curGroup = groupRow[c]; const detail = detailRow[c];
const name = headerRow[c]; const group = groupFilled[c];
if (!name) { colNames.push(null); continue; } if (detail) {
colNames.push(nameCounts[name] > 1 && curGroup ? `${curGroup} / ${name}` : name); // Use group prefix if duplicate detail names or if group differs from detail
const needPrefix = group && group !== detail && (detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
colNames.push(needPrefix ? `${group} / ${detail}` : detail);
} else if (group) {
colNames.push(group);
} else {
colNames.push(null);
}
} }
// 4. Skip sub-header rows after header (text-only rows before first numeric row) // 4. Data starts at firstDataIdx
let dataStart = headerIdx + 1; const dataStart = firstDataIdx;
while (dataStart < rows.length && !hasNumber(rows[dataStart])) dataStart++;
// 5. Convert data rows to objects // 5. Convert data rows to objects
const data = []; const data = [];
@@ -551,7 +590,7 @@ export async function readSpreadsheet() {
} }
// 6. Meta: title, params, filters from rows before header // 6. Meta: title, params, filters from rows before header
const metaEnd = groupRow ? headerIdx - 1 : headerIdx; const metaEnd = groupIdx >= 0 ? groupIdx : detailIdx;
let title = ''; let title = '';
const meta = []; const meta = [];
for (let i = 0; i < metaEnd; i++) { for (let i = 0; i < metaEnd; i++) {