AURORA-1984: самостоятельный проект — уровень программы в дереве и в выгрузке

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 23:05:59 +04:00
parent b87b12f980
commit f5ce14fd11
5 changed files with 90 additions and 6 deletions

View File

@ -22,6 +22,19 @@ RowFormatResolver = Callable[[tuple, str, int], dict[str, Any] | None]
ColumnFormatResolver = Callable[[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]): def _hierarchical_rows(rows: list[tuple]):
"""Use the same row-type hierarchy as the realtime table, skipping META.""" """Use the same row-type hierarchy as the realtime table, skipping META."""
levels = {"ROOT": 0, "GROUP": 1, "ITEM": 2, "SUB_ITEM": 3, "INPUT": 4} 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 item_type = row[0] if row else None
if item_type == "META": if item_type == "META":
continue 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: while stack and stack[-1] >= level:
stack.pop() stack.pop()
yield row, len(stack) yield row, len(stack)

View File

@ -239,3 +239,23 @@ def test_standalone_project_outline_without_group():
assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [ assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [
("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1), ("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]

View File

@ -73,7 +73,7 @@ ROOT выводится только при наличии расходов в
- Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем. - Одновременное создание проектов двумя пользователями: сервер упорядочивает по id; клиент вставляет по `sort_order` из ответа; возможное расхождение позиции до перезагрузки допустимо, отдельных тестов гонки не делаем.
### D7a. Вид в выгрузке и на экране ### 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: переименование ### D8. AURORA-1980: переименование
- Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла). - Кнопка «Переименовать» в группе «Строки» панели (только FORM_4), активна при выделенной программе или проекте. Окно — отдельный компонент `RenameNodeModal` с начальным значением и заголовком «Переименование программы/проекта»: `CreateProgramModal` закрывается сразу после отправки и не принимает начальное значение. Состояние окна — в `RealtimeTable` рядом с `pendingDelete` (редьюсер `SheetModals` не хранит данных узла).

View File

@ -6,24 +6,38 @@ const ROW_TYPE_LEVELS = {
INPUT: 4, 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) { export function buildRowHierarchy(items, mapItem = (item) => item) {
const result = []; const result = [];
const stack = []; const stack = [];
for (const [index, item] of items.entries()) { 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(); stack.pop();
} }
const parent = stack.at(-1); const parent = stack.at(-1)?.node;
const newNode = { ...mapItem(item, index), subRows: [] }; const newNode = { ...mapItem(item, index), subRows: [] };
if (parent) parent.subRows.push(newNode); if (parent) parent.subRows.push(newNode);
else result.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; return result;

View File

@ -9,6 +9,7 @@ import {
getProjectAddTarget, getProjectAddTarget,
getRenameTarget, getRenameTarget,
isRenameResponse, isRenameResponse,
isStandaloneProject,
} from './rowUtils.js'; } from './rowUtils.js';
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => { 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('INPUT', { program_id: 11, project_id: 21 })), null);
assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), 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);
});