mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-28 07:31:00 +03:00
Compare commits
3
Commits
974e8ff5e4
...
c8a7ba4683
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a7ba4683 | ||
|
|
ea8b28280d | ||
|
|
eebc2a0679 |
@@ -163,6 +163,15 @@ Shorthand: `"Параметр = значение [when условие] [for По
|
||||
|
||||
Не поддерживает пакетный режим. Value — полный текст запроса или `@path/to/file.sql` (ссылка на внешний файл). Путь разрешается относительно Template.xml, затем CWD.
|
||||
|
||||
### patch-query — точечная замена в тексте запроса
|
||||
|
||||
Shorthand: `"старое => новое"`. Заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
|
||||
|
||||
```
|
||||
"СубконтоДт1) В => СубконтоКт1) В"
|
||||
"ЛЕВОЕ СОЕДИНЕНИЕ => ВНУТРЕННЕЕ СОЕДИНЕНИЕ"
|
||||
```
|
||||
|
||||
### set-outputParameter — установить параметр вывода
|
||||
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# skd-edit v1.2 — Atomic 1C DCS editor
|
||||
# skd-edit v1.3 — Atomic 1C DCS editor
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -9,7 +9,7 @@ param(
|
||||
"add-field","add-total","add-calculated-field","add-parameter","add-filter",
|
||||
"add-dataParameter","add-order","add-selection","add-dataSetLink",
|
||||
"add-dataSet","add-variant","add-conditionalAppearance",
|
||||
"set-query","set-outputParameter","set-structure",
|
||||
"set-query","patch-query","set-outputParameter","set-structure",
|
||||
"modify-field","modify-filter","modify-dataParameter",
|
||||
"clear-selection","clear-order","clear-filter",
|
||||
"remove-field","remove-total","remove-calculated-field","remove-parameter","remove-filter")]
|
||||
@@ -1740,6 +1740,34 @@ switch ($Operation) {
|
||||
Write-Host "[OK] Query replaced in dataset `"$dsName`""
|
||||
}
|
||||
|
||||
"patch-query" {
|
||||
$dsNode = Resolve-DataSet
|
||||
$dsName = Get-DataSetName $dsNode
|
||||
|
||||
$queryEl = Find-FirstElement $dsNode @("query") $schNs
|
||||
if (-not $queryEl) {
|
||||
Write-Error "No <query> element found in dataset '$dsName'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
foreach ($val in $values) {
|
||||
$sepIdx = $val.IndexOf(" => ")
|
||||
if ($sepIdx -lt 0) {
|
||||
Write-Error "patch-query value must contain ' => ' separator: old => new"
|
||||
exit 1
|
||||
}
|
||||
$oldStr = $val.Substring(0, $sepIdx)
|
||||
$newStr = $val.Substring($sepIdx + 4)
|
||||
$queryText = $queryEl.InnerText
|
||||
if (-not $queryText.Contains($oldStr)) {
|
||||
Write-Error "Substring not found in query of dataset '$dsName': $oldStr"
|
||||
exit 1
|
||||
}
|
||||
$queryEl.InnerText = $queryText.Replace($oldStr, $newStr)
|
||||
Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'"
|
||||
}
|
||||
}
|
||||
|
||||
"set-outputParameter" {
|
||||
$settings = Resolve-VariantSettings
|
||||
$varName = Get-VariantName
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# skd-edit v1.2 — Atomic 1C DCS editor (Python port)
|
||||
# skd-edit v1.3 — Atomic 1C DCS editor (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import os
|
||||
@@ -17,7 +17,7 @@ VALID_OPS = [
|
||||
"add-field", "add-total", "add-calculated-field", "add-parameter", "add-filter",
|
||||
"add-dataParameter", "add-order", "add-selection", "add-dataSetLink",
|
||||
"add-dataSet", "add-variant", "add-conditionalAppearance",
|
||||
"set-query", "set-outputParameter", "set-structure",
|
||||
"set-query", "patch-query", "set-outputParameter", "set-structure",
|
||||
"modify-field", "modify-filter", "modify-dataParameter",
|
||||
"clear-selection", "clear-order", "clear-filter",
|
||||
"remove-field", "remove-total", "remove-calculated-field", "remove-parameter", "remove-filter",
|
||||
@@ -1488,6 +1488,27 @@ elif operation == "set-query":
|
||||
query_el.text = resolve_query_value(value_arg, query_base_dir)
|
||||
print(f'[OK] Query replaced in dataset "{ds_name}"')
|
||||
|
||||
elif operation == "patch-query":
|
||||
ds_node = resolve_data_set()
|
||||
ds_name = get_data_set_name(ds_node)
|
||||
query_el = find_first_element(ds_node, ["query"], SCH_NS)
|
||||
if query_el is None:
|
||||
print(f"No <query> element found in dataset '{ds_name}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for val in values:
|
||||
sep_idx = val.find(" => ")
|
||||
if sep_idx < 0:
|
||||
print("patch-query value must contain ' => ' separator: old => new", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
old_str = val[:sep_idx]
|
||||
new_str = val[sep_idx + 4:]
|
||||
query_text = query_el.text or ""
|
||||
if old_str not in query_text:
|
||||
print(f"Substring not found in query of dataset '{ds_name}': {old_str}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
query_el.text = query_text.replace(old_str, new_str)
|
||||
print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'')
|
||||
|
||||
elif operation == "set-outputParameter":
|
||||
settings = resolve_variant_settings()
|
||||
var_name = get_variant_name()
|
||||
|
||||
@@ -240,6 +240,24 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||
const t = await readTable();
|
||||
t.rows.filter(r => r._selected); // rows with _selected: true
|
||||
```
|
||||
- **SpreadsheetDocument cells** (report drill-down): first argument can be `{ row, column }` object to click a cell in a rendered report. Coordinates match `readSpreadsheet()` output:
|
||||
```js
|
||||
const report = await readSpreadsheet();
|
||||
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... }
|
||||
|
||||
// By data row index + column header name
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// By cell value filter (fuzzy match)
|
||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// Totals row
|
||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true });
|
||||
```
|
||||
Text search also works as fallback — searches inside spreadsheet iframes:
|
||||
```js
|
||||
await clickElement('150 000', { dblclick: true }); // finds cell by text in report
|
||||
```
|
||||
|
||||
#### `fillFields({ name: value })` → `{ filled, form }`
|
||||
Fill form fields by label (fuzzy match). Auto-detects field type.
|
||||
@@ -410,6 +428,23 @@ console.log('Title:', report.title);
|
||||
console.log('Data rows:', report.data?.length);
|
||||
```
|
||||
|
||||
### Drill-down report cells
|
||||
|
||||
```js
|
||||
// Generate report
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
|
||||
// Double-click cell to open drill-down (uses coordinates from readSpreadsheet)
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
||||
// Modal dialog "Выбор поля" opens
|
||||
await clickElement('Регистратор');
|
||||
await clickElement('Выбрать');
|
||||
await wait(10);
|
||||
const drilldown = await readSpreadsheet();
|
||||
```
|
||||
|
||||
### Work with multi-grid forms
|
||||
|
||||
Some forms have multiple grids (e.g. "Входящие" and "Исходящие" tables on a single form). Without `table`, buttons like "Добавить" hit the first match and `readTable` reads the first grid — which may not be the one you need.
|
||||
@@ -441,7 +476,12 @@ await deleteTableRow(0, { table: 'Исходящие' });
|
||||
|
||||
Table matching accepts both technical name (`tables[].name`) and visual label (`tables[].label`). Label is the group title shown on screen — useful when working from screenshots. Name match takes priority over label match.
|
||||
|
||||
### Keyboard shortcuts (via `page.keyboard.press`)
|
||||
### Keyboard shortcuts
|
||||
|
||||
```js
|
||||
const page = await getPage();
|
||||
await page.keyboard.press('F8'); // example: create new item in focused reference field
|
||||
```
|
||||
|
||||
| Key | Context | Action |
|
||||
|-----|---------|--------|
|
||||
|
||||
@@ -964,20 +964,14 @@ export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||||
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <span>.
|
||||
*
|
||||
* 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());
|
||||
// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---
|
||||
|
||||
// Collect iframe indices that belong to the current form's spreadsheet container
|
||||
/**
|
||||
* 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 iframeIndices = await page.evaluate(`(() => {
|
||||
const prefix = 'form${formNum ?? 0}_';
|
||||
const allIframes = [...document.querySelectorAll('iframe')];
|
||||
@@ -996,11 +990,11 @@ export async function readSpreadsheet() {
|
||||
|
||||
const frames = page.frames();
|
||||
const allCells = new Map();
|
||||
const frameMap = new Map(); // key 'r_c' → Playwright frame index
|
||||
|
||||
// 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];
|
||||
const frameIndex = iframeIdx + 1;
|
||||
const frame = frames[frameIndex];
|
||||
if (!frame) continue;
|
||||
try {
|
||||
const cells = await frame.evaluate(`(() => {
|
||||
@@ -1020,14 +1014,20 @@ export async function readSpreadsheet() {
|
||||
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, frameIndex);
|
||||
}
|
||||
}
|
||||
} catch { /* skip inaccessible frames */ }
|
||||
}
|
||||
return { allCells, frameMap };
|
||||
}
|
||||
|
||||
if (allCells.size === 0) throw new Error('readSpreadsheet: no SpreadsheetDocument found. Report may not be generated yet.');
|
||||
|
||||
// Group by row, determine max columns
|
||||
/**
|
||||
* 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()) {
|
||||
@@ -1038,38 +1038,35 @@ export async function readSpreadsheet() {
|
||||
|
||||
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||||
const rows = sortedRows.map(r => {
|
||||
const colMap = rowMap.get(r);
|
||||
const cm = rowMap.get(r);
|
||||
const arr = [];
|
||||
for (let c = 0; c <= maxCol; c++) arr.push(colMap.get(c) || '');
|
||||
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||||
return arr;
|
||||
});
|
||||
|
||||
// --- Structured parsing ---
|
||||
const hasNumber = (row) => row.some(c => /^[\d\s\u00a0]/.test(c) && /\d/.test(c));
|
||||
const nonEmpty = (row) => row.filter(c => c !== '').length;
|
||||
|
||||
// 1. Find first data row (first row with numbers)
|
||||
// Find first data row (first row with numbers)
|
||||
let firstDataIdx = rows.length;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (hasNumber(rows[i])) { firstDataIdx = i; break; }
|
||||
}
|
||||
|
||||
// 2. Find header rows: scan backwards from data, pick last row with ≥3 cells as detail header
|
||||
// Find header rows
|
||||
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 };
|
||||
if (detailIdx === -1) return null; // no headers detected
|
||||
|
||||
// Group header: row before detail with ≥2 non-empty cells
|
||||
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;
|
||||
|
||||
// 3. Build column names by merging group + detail rows
|
||||
// Fill-forward group names across empty columns (merged cells)
|
||||
// Build column names (group + detail merge)
|
||||
const groupFilled = new Array(maxCol + 1).fill('');
|
||||
if (groupRow) {
|
||||
let cur = '';
|
||||
@@ -1079,8 +1076,6 @@ export async function readSpreadsheet() {
|
||||
}
|
||||
}
|
||||
|
||||
// For each column: use detail name if available, else group name
|
||||
// Prefix with group when duplicates exist in detail row
|
||||
const detailCounts = {};
|
||||
for (let c = 0; c <= maxCol; c++) {
|
||||
const n = detailRow[c];
|
||||
@@ -1092,7 +1087,6 @@ export async function readSpreadsheet() {
|
||||
const detail = detailRow[c];
|
||||
const group = groupFilled[c];
|
||||
if (detail) {
|
||||
// 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) {
|
||||
@@ -1102,10 +1096,192 @@ export async function readSpreadsheet() {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Data starts at firstDataIdx
|
||||
const dataStart = firstDataIdx;
|
||||
// Column name → physical column index
|
||||
const colMap = new Map();
|
||||
for (let c = 0; c < colNames.length; c++) {
|
||||
if (colNames[c]) colMap.set(colNames[c], c);
|
||||
}
|
||||
|
||||
// 5. Convert data rows to objects
|
||||
// Classify data rows: separate data indices and totals index
|
||||
const dataRowIndices = []; // indices into rows[] array
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows, sortedRows, maxCol, colNames, colMap,
|
||||
headerRowIdx: detailIdx, groupRowIdx: groupIdx,
|
||||
dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
|
||||
rowMap, hasNumber, nonEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
const colName = target.column;
|
||||
if (!colMap.has(colName)) {
|
||||
const available = colNames.filter(n => n);
|
||||
throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
|
||||
}
|
||||
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() || '';
|
||||
rowIdx = dataRowIndices.find(i => {
|
||||
return filterEntries.every(([fCol, fVal]) => {
|
||||
const fColIdx = colMap.get(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 frameIndex = frameMap.get(cellKey);
|
||||
if (!frameIndex) {
|
||||
// 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.`);
|
||||
}
|
||||
|
||||
// Get bounding box and click via page.mouse (bypasses mxlCurrBody overlay)
|
||||
const frame = page.frames()[frameIndex];
|
||||
const cellLoc = frame.locator(`div[x="${physCol}"]`).filter({ has: frame.locator(`xpath=ancestor::div[@y="${physRow}" or contains(@class,"R${physRow}")]`) });
|
||||
// Simpler: find by CSS class RxCy
|
||||
const cellDiv = frame.locator(`div.R${physRow}C${physCol}`).first();
|
||||
const box = await cellDiv.boundingBox();
|
||||
if (!box) throw new Error(`clickElement: cell R${physRow}C${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();
|
||||
const state = await getFormState();
|
||||
state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) };
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
|
||||
* Returns { frameIndex, physRow, physCol, box } or null if not found.
|
||||
*/
|
||||
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 frameIndex = frameMap.get(found.key);
|
||||
if (!frameIndex) return null;
|
||||
|
||||
const frame = page.frames()[frameIndex];
|
||||
const cellDiv = frame.locator(`div.R${found.cell.r}C${found.cell.c}`).first();
|
||||
const box = await cellDiv.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
return { frameIndex, 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 <span>.
|
||||
*
|
||||
* 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) throw new Error('readSpreadsheet: no SpreadsheetDocument found. 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, hasNumber, nonEmpty } = mapping;
|
||||
|
||||
// Convert data rows to objects
|
||||
const data = [];
|
||||
let totals = null;
|
||||
const toObj = (row) => {
|
||||
@@ -1116,7 +1292,7 @@ export async function readSpreadsheet() {
|
||||
return obj;
|
||||
};
|
||||
|
||||
for (let i = dataStart; i < rows.length; i++) {
|
||||
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 === 'всего') {
|
||||
@@ -1126,8 +1302,8 @@ export async function readSpreadsheet() {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Meta: title, params, filters from rows before header
|
||||
const metaEnd = groupIdx >= 0 ? groupIdx : detailIdx;
|
||||
// Meta: title, params, filters from rows before header
|
||||
const metaEnd = groupRowIdx >= 0 ? groupRowIdx : headerRowIdx;
|
||||
let title = '';
|
||||
const meta = [];
|
||||
for (let i = 0; i < metaEnd; i++) {
|
||||
@@ -1931,9 +2107,15 @@ export async function fillField(name, value) {
|
||||
return fillFields({ [name]: value });
|
||||
}
|
||||
|
||||
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). */
|
||||
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
|
||||
* First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */
|
||||
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) {
|
||||
ensureConnected();
|
||||
// Dispatch to spreadsheet cell handler when first arg is { row, column }
|
||||
if (typeof text === 'object' && text !== null && text.column != null) {
|
||||
await dismissPendingErrors();
|
||||
return clickSpreadsheetCell(text, { dblclick, modifier });
|
||||
}
|
||||
await dismissPendingErrors();
|
||||
if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||
let netMonitor = null;
|
||||
@@ -2037,7 +2219,24 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target?.error) throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
||||
// Fallback: search spreadsheet iframes for text match before giving up
|
||||
if (target?.error) {
|
||||
const ssCell = await findSpreadsheetCellByText(formNum, text);
|
||||
if (ssCell) {
|
||||
const cx = ssCell.box.x + ssCell.box.width / 2;
|
||||
const cy = ssCell.box.y + ssCell.box.height / 2;
|
||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||
if (modKey) await page.keyboard.down(modKey);
|
||||
if (dblclick) await page.mouse.dblclick(cx, cy);
|
||||
else await page.mouse.click(cx, cy);
|
||||
if (modKey) await page.keyboard.up(modKey);
|
||||
await waitForStable();
|
||||
const state = await getFormState();
|
||||
state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) };
|
||||
return state;
|
||||
}
|
||||
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
||||
}
|
||||
|
||||
// Helper: click with optional modifier key (Ctrl/Shift for multi-select)
|
||||
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|-------|-----------|----------|
|
||||
| `/skd-info` | `<TemplatePath> [-Mode] [-Name]` | Анализ структуры СКД: наборы, поля, параметры, ресурсы, варианты (11 режимов, включая full) |
|
||||
| `/skd-compile` | `[-DefinitionFile <json> \| -Value <json-string>] -OutputPath <Template.xml>` | Генерация Template.xml из JSON DSL: наборы, поля, итоги, параметры, варианты |
|
||||
| `/skd-edit` | `<TemplatePath> -Operation <op> -Value "<value>"` | Точечное редактирование: 25 атомарных операций (add/set/modify/clear/remove) |
|
||||
| `/skd-edit` | `<TemplatePath> -Operation <op> -Value "<value>"` | Точечное редактирование: 26 атомарных операций (add/set/patch/modify/clear/remove) |
|
||||
| `/skd-validate` | `<TemplatePath> [-MaxErrors 20]` | Валидация структурной корректности: ~30 проверок |
|
||||
|
||||
## Рабочий цикл
|
||||
|
||||
+50
-2
@@ -189,6 +189,27 @@ await closeForm({ save: false });
|
||||
|
||||
Запуск: `node $RUN run http://localhost:8081/erp scenario-compare-stocks.js`
|
||||
|
||||
### Расшифровка отчёта
|
||||
|
||||
```js
|
||||
// 1. Сформировать отчёт
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
|
||||
// 2. Двойной клик по ячейке → диалог "Выбор поля"
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// 3. Выбрать поле расшифровки
|
||||
await clickElement('Регистратор');
|
||||
await clickElement('Выбрать');
|
||||
await wait(10);
|
||||
|
||||
// 4. Прочитать результат
|
||||
const drilldown = await readSpreadsheet();
|
||||
console.log('Расшифровка:', JSON.stringify(drilldown.rows));
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Все функции доступны как глобальные переменные в скриптах. `console.log()` выводит данные в ответ.
|
||||
@@ -239,11 +260,35 @@ await closeForm({ save: false });
|
||||
- `_selected: true` — строка выделена (подсвечена). Используйте с `clickElement({ modifier: 'ctrl'|'shift' })` для проверки мультиселекции
|
||||
- На объекте результата: `hierarchical: true`, `viewMode: 'tree'`
|
||||
|
||||
#### clickElement — клик по ячейке SpreadsheetDocument
|
||||
|
||||
Для расшифровки отчётов первый аргумент `clickElement` принимает объект `{ row, column }` вместо текста. Координаты соответствуют выводу `readSpreadsheet()`:
|
||||
|
||||
```js
|
||||
const report = await readSpreadsheet();
|
||||
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000' }
|
||||
|
||||
// По индексу строки данных + имя колонки
|
||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// По значению ячейки в строке (fuzzy match)
|
||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true });
|
||||
|
||||
// Строка итогов
|
||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true });
|
||||
```
|
||||
|
||||
Текстовый поиск тоже работает — если элемент не найден в основном DOM, `clickElement` ищет в SpreadsheetDocument iframe'ах:
|
||||
|
||||
```js
|
||||
await clickElement('150 000', { dblclick: true }); // найдёт ячейку в отчёте
|
||||
```
|
||||
|
||||
### Действия
|
||||
|
||||
| Функция | Описание | Возвращает |
|
||||
|---------|----------|------------|
|
||||
| `clickElement(text, {dblclick?, modifier?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции | form state или `{ submenu }` |
|
||||
| `clickElement(text, {dblclick?, modifier?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке SpreadsheetDocument (см. выше) | form state или `{ submenu }` |
|
||||
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | `{ filled: [{field, ok, method}], form }` |
|
||||
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
|
||||
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state |
|
||||
@@ -288,7 +333,10 @@ await closeForm({ save: false });
|
||||
|
||||
## Клавиатурные сочетания
|
||||
|
||||
Через `getPage().keyboard.press()`:
|
||||
```js
|
||||
const page = await getPage();
|
||||
await page.keyboard.press('F8'); // пример: создать новый элемент в сфокусированном ссылочном поле
|
||||
```
|
||||
|
||||
| Клавиша | Контекст | Действие |
|
||||
|---------|----------|----------|
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Точечная замена в тексте запроса",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ 1 КАК Поле",
|
||||
"fields": ["Поле"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "patch-query",
|
||||
"value": "1 КАК Поле => Т.Наименование КАК Имя"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Поле</dataPath>
|
||||
<field>Поле</field>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Наименование КАК Имя</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
Reference in New Issue
Block a user