From 338faa253d557851037341a5b8424bb6e70e4114 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 7 Apr 2026 13:41:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20multi-row=20grid=20support=20?= =?UTF-8?q?in=20readTable=20=E2=80=94=20split=20merged=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readTable now handles multi-row grids (e.g. accounting journal entries) where a single column header spans multiple data sub-rows: - "Субконто Дт" with 3 data cells → "Субконто Дт 1", "Субконто Дт 2", "Субконто Дт 3" - Stacked headers (2+ at same X) matched by Y-order (e.g. "Счет Дт" / "Подразделение Дт") - getFormState tables[].columns also expanded for consistency - Flat/simple grids unaffected (no multi-row detection triggers) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 121 ++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index b1bf7cca..19310abd 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -232,7 +232,8 @@ const READ_FORM_FN = `function readForm(p) { const textEl = box.querySelector('.gridBoxText'); const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; if (text) { - columns.push(text); + const r = box.getBoundingClientRect(); + columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height }); } else { // Unnamed column — check if data cells contain checkboxes const firstLine = body?.querySelector('.gridLine'); @@ -241,18 +242,47 @@ const READ_FORM_FN = `function readForm(p) { const idx = visibleHeaders.indexOf(box); const cells = [...firstLine.children].filter(c => c.offsetWidth > 0); if (cells[idx]?.querySelector('.checkbox')) { - columns.push('(checkbox)'); + columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 }); } } } }); + // Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3) + const firstLine = body?.querySelector('.gridLine'); + if (firstLine && columns.length > 0) { + const xGrp = new Map(); + columns.forEach(c => { + const k = Math.round(c.x) + ':' + Math.round(c.right); + if (!xGrp.has(k)) xGrp.set(k, []); + xGrp.get(k).push(c); + }); + for (const [k, hdrs] of xGrp) { + if (hdrs.length !== 1) continue; + let cnt = 0; + [...firstLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const r = box.getBoundingClientRect(); + const cx = r.x + r.width / 2; + if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++; + }); + if (cnt > 1) { + const base = hdrs[0]; + const baseIdx = columns.indexOf(base); + columns.splice(baseIdx, 1); + for (let si = 0; si < cnt; si++) { + columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 }); + } + } + } + } } + const colNames = columns.map(c => c.text); const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; // Visual label from group title (e.g. "Входящие:" for grid "Входящие") const titleEl = document.getElementById(p + name + '#title_div') || document.getElementById(p + 'Группа' + name + '#title_div'); const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null; - return { name, columns, rowCount, ...(label ? { label } : {}) }; + return { name, columns: colNames, rowCount, ...(label ? { label } : {}) }; }); result.tables = tables; // Backward compat: table = first grid summary @@ -536,15 +566,91 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto const cells = [...firstLine.children].filter(c => c.offsetWidth > 0); if (cells[idx]?.querySelector('.checkbox')) { const r = box.getBoundingClientRect(); - columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width }); + columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); } } return; } const r = box.getBoundingClientRect(); - columns.push({ text, x: r.x, w: r.width, right: r.x + r.width }); + columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); }); + // Multi-row grid support: detect stacked/merged headers. + // Group headers by X-range. For each group, count data sub-rows from first line. + // - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order + // - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1") + const xGroups = new Map(); + columns.forEach(c => { + const key = Math.round(c.x) + ':' + Math.round(c.right); + if (!xGroups.has(key)) xGroups.set(key, []); + xGroups.get(key).push(c); + }); + for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y); + + const firstDataLine = body?.querySelector('.gridLine'); + const subRowMap = new Map(); + if (firstDataLine) { + [...firstDataLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const r = box.getBoundingClientRect(); + const cx = r.x + r.width / 2; + for (const [key, hdrs] of xGroups) { + const h0 = hdrs[0]; + if (cx >= h0.x && cx < h0.right) { + if (!subRowMap.has(key)) subRowMap.set(key, []); + subRowMap.get(key).push({ y: r.y }); + break; + } + } + }); + for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y); + } + + const multiRowGroups = new Map(); + for (const [key, hdrs] of xGroups) { + const subs = subRowMap.get(key); + if (!subs || subs.length <= 1) continue; + if (hdrs.length >= 2) { + multiRowGroups.set(key, hdrs); + } else if (hdrs.length === 1 && subs.length > 1) { + const base = hdrs[0]; + const baseIdx = columns.indexOf(base); + columns.splice(baseIdx, 1); + const expanded = []; + for (let si = 0; si < subs.length; si++) { + const numbered = { + text: base.text + ' ' + (si + 1), + x: base.x, w: base.w, right: base.right, + y: base.y + si, h: base.h / subs.length, _subIdx: si + }; + columns.splice(baseIdx + si, 0, numbered); + expanded.push(numbered); + } + multiRowGroups.set(key, expanded); + } + } + + function matchColumn(cellX, cellW, cellY) { + const cx = cellX + cellW / 2; + for (const [key, hdrs] of multiRowGroups) { + const h0 = hdrs[0]; + if (cx >= h0.x && cx < h0.right) { + const subs = subRowMap.get(key); + if (subs) { + const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5); + if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx]; + } + let best = hdrs[0], bestDist = Infinity; + for (const h of hdrs) { + const dist = Math.abs(cellY - h.y); + if (dist < bestDist) { bestDist = dist; best = h; } + } + return best; + } + } + return columns.find(c => cx >= c.x && cx < c.right); + } + // Extract data rows from gridBody const allLines = body.querySelectorAll('.gridLine'); const total = allLines.length; @@ -566,10 +672,9 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; if (!val) return; } - // Match cell to column by X-coordinate overlap + // Match cell to column by X+Y overlap (multi-row aware) const r = box.getBoundingClientRect(); - const cx = r.x + r.width / 2; - const col = columns.find(c => cx >= c.x && cx < c.right); + const col = matchColumn(r.x, r.width, r.y); if (col) { row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val; }