diff --git a/api/src/services/export_service/export_writers.py b/api/src/services/export_service/export_writers.py index fbd528a..d39a7c0 100644 --- a/api/src/services/export_service/export_writers.py +++ b/api/src/services/export_service/export_writers.py @@ -22,6 +22,19 @@ RowFormatResolver = Callable[[tuple, str, int], dict[str, Any] | None] ColumnFormatResolver = Callable[[str, int], dict[str, Any] | None] +def _is_standalone_project(row: tuple) -> bool: + """AURORA-1984 (миграция 0059, design D3): самостоятельный проект формы 4 — + ITEM с header.project_id и без header.program_id. Он ровня программе (GROUP).""" + if not row or row[0] != "ITEM" or len(row) <= 3 or not isinstance(row[3], dict): + return False + header = row[3].get("header") + return ( + isinstance(header, dict) + and header.get("project_id") is not None + and header.get("program_id") is None + ) + + def _hierarchical_rows(rows: list[tuple]): """Use the same row-type hierarchy as the realtime table, skipping META.""" levels = {"ROOT": 0, "GROUP": 1, "ITEM": 2, "SUB_ITEM": 3, "INPUT": 4} @@ -30,7 +43,7 @@ def _hierarchical_rows(rows: list[tuple]): item_type = row[0] if row else None if item_type == "META": continue - level = levels.get(item_type, 999) + level = levels["GROUP"] if _is_standalone_project(row) else levels.get(item_type, 999) while stack and stack[-1] >= level: stack.pop() yield row, len(stack) diff --git a/api/tests/unit/test_export_writers.py b/api/tests/unit/test_export_writers.py index 8d23f69..f5739d5 100644 --- a/api/tests/unit/test_export_writers.py +++ b/api/tests/unit/test_export_writers.py @@ -239,3 +239,23 @@ def test_standalone_project_outline_without_group(): assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [ ("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1), ] + + +def test_standalone_project_after_program_is_sibling_of_program(): + """AURORA-1984 I-1: самостоятельный проект после программы — уровень программы.""" + + def node(kind, **header): + return (kind, 0, 0, {"header": header, "line_id": None}) + + rows = [ + node("ROOT"), + node("GROUP", program_id=1), + node("ITEM", project_id=10, program_id=1), + ("INPUT", 4, 0, {"header": {"project_id": 10, "program_id": 1}, "line_id": 5}), + node("ITEM", project_id=11, program_id=None), + ("INPUT", 4, 0, {"header": {"project_id": 11}, "line_id": 6}), + node("GROUP", program_id=2), + node("ITEM", project_id=12), + ("INPUT", 4, 0, {"header": {"project_id": 12}, "line_id": 7}), + ] + assert [level for _, level in _hierarchical_rows(rows)] == [0, 1, 2, 3, 1, 2, 1, 1, 2] diff --git a/openspec/changes/aurora-1984-pd-standalone-projects/design.md b/openspec/changes/aurora-1984-pd-standalone-projects/design.md index ff0fb4e..9a6628c 100644 --- a/openspec/changes/aurora-1984-pd-standalone-projects/design.md +++ b/openspec/changes/aurora-1984-pd-standalone-projects/design.md @@ -73,7 +73,7 @@ ROOT выводится только при наличии расходов в - Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем. ### D7a. Вид в выгрузке и на экране -Самостоятельный ITEM: раскраска — как у проекта (`row_type` ITEM); отступ названия и уровень группировки (outline) — как у программы (1): и на экране (`Cell.jsx`, `row.depth` дерева), и в Excel (`alignment.indent = outline_level`) они считаются по фактической вложенности, это получается само из `export_writers._hierarchical_rows` (стек по `row_type`), правка писателя не нужна. Значение `depth = 2` в данных нужно только для поиска строки в `updateCells` (`findRowPath` сравнивает его с серверным). На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. +Самостоятельный ITEM: раскраска — как у проекта (`row_type` ITEM); отступ названия и уровень группировки (outline) — как у программы (1): и на экране (`Cell.jsx`, `row.depth` дерева), и в Excel (`alignment.indent = outline_level`) они считаются по фактической вложенности, это НЕ получается само: оба построителя вкладывают строки по `row_type` (ITEM глубже GROUP), и самостоятельный проект после программы оказался бы её потомком (свёртка программы прячет его, в Excel уровни 2/3). Поэтому построители распознают самостоятельный проект явно (ITEM, `header.project_id != null`, `header.program_id == null`) и ставят его на уровень GROUP: `isStandaloneProject` в `rowUtils.js` (`buildRowHierarchy`) и `_is_standalone_project` в `export_writers.py` (`_hierarchical_rows`) (находка I-1 / B1 живой проверки). Значение `depth = 2` в данных нужно только для поиска строки в `updateCells` (`findRowPath` сравнивает его с серверным). На экране свёртка — по `row_type` (`buildRowHierarchy`), так же. Эталон теста выгрузки: строки самостоятельного проекта — ITEM под ROOT, без GROUP, outline 1, расходы под ним outline 2. ### D8. AURORA-1980: переименование - Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла). diff --git a/web/src/components/RealtimeTable/utils/rowUtils.js b/web/src/components/RealtimeTable/utils/rowUtils.js index 3ccfdf4..3f6c47e 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.js @@ -6,24 +6,38 @@ const ROW_TYPE_LEVELS = { INPUT: 4, }; +// AURORA-1984 (миграция 0059, design D3): самостоятельный проект формы 4 — ITEM с +// header.project_id и без header.program_id. По row_type он глубже GROUP, но по +// смыслу — ровня программе (прямой потомок ROOT), где бы он ни стоял в порядке листа. +// Строки других форм/листов project_id в header не имеют, их это не затрагивает. +export function isStandaloneProject(item) { + const header = item?.data?.header; + return item?.row_type === 'ITEM' && header?.project_id != null && header?.program_id == null; +} + +function getHierarchyLevel(item) { + if (isStandaloneProject(item)) return ROW_TYPE_LEVELS.GROUP; + return ROW_TYPE_LEVELS[item.row_type] ?? 999; +} + export function buildRowHierarchy(items, mapItem = (item) => item) { const result = []; const stack = []; for (const [index, item] of items.entries()) { - const currentLevel = ROW_TYPE_LEVELS[item.row_type] ?? 999; + const currentLevel = getHierarchyLevel(item); - while (stack.length > 0 && (ROW_TYPE_LEVELS[stack.at(-1).row_type] ?? 999) >= currentLevel) { + while (stack.length > 0 && stack.at(-1).level >= currentLevel) { stack.pop(); } - const parent = stack.at(-1); + const parent = stack.at(-1)?.node; const newNode = { ...mapItem(item, index), subRows: [] }; if (parent) parent.subRows.push(newNode); else result.push(newNode); - if (item.row_type !== 'INPUT') stack.push(newNode); + if (item.row_type !== 'INPUT') stack.push({ node: newNode, level: currentLevel }); } return result; diff --git a/web/src/components/RealtimeTable/utils/rowUtils.test.js b/web/src/components/RealtimeTable/utils/rowUtils.test.js index 0cec593..b817690 100644 --- a/web/src/components/RealtimeTable/utils/rowUtils.test.js +++ b/web/src/components/RealtimeTable/utils/rowUtils.test.js @@ -9,6 +9,7 @@ import { getProjectAddTarget, getRenameTarget, isRenameResponse, + isStandaloneProject, } from './rowUtils.js'; test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { @@ -134,3 +135,39 @@ test('AURORA-1979: getForm4TypeLabel', () => { assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null); assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null); }); + +const node = (row_type, header = {}, extra = {}) => ({ row_type, data: { header, ...extra } }); +const names = (rows) => rows.map((r) => [r.data.header.name, names(r.subRows)]); +const rows = [ + node('ROOT', { name: 'root' }), + node('GROUP', { name: 'prog1', program_id: 1 }), + node('ITEM', { name: 'in-prog', project_id: 10, program_id: 1 }), + node('INPUT', { name: 'e1', project_id: 10, program_id: 1 }, { line_id: 1 }), + node('ITEM', { name: 'solo', project_id: 11 }), + node('INPUT', { name: 'e2', project_id: 11 }, { line_id: 2 }), + node('GROUP', { name: 'prog2', program_id: 2 }), + node('ITEM', { name: 'solo2', project_id: 12, program_id: null }), + node('INPUT', { name: 'e3', project_id: 12 }, { line_id: 3 }), +]; + +test('I-1: standalone ITEM после программы — потомок ROOT, расходы — его дети', () => { + const tree = buildRowHierarchy(rows); + assert.deepEqual(names(tree), [ + [ + 'root', + [ + ['prog1', [['in-prog', [['e1', []]]]]], + ['solo', [['e2', []]]], + ['prog2', []], + ['solo2', [['e3', []]]], + ], + ], + ]); +}); + +test('I-1: isStandaloneProject различает узлы', () => { + assert.equal(isStandaloneProject(rows[4]), true); + assert.equal(isStandaloneProject(rows[2]), false); + assert.equal(isStandaloneProject(rows[1]), false); + assert.equal(isStandaloneProject(rows[5]), false); +});