AURORA-1984: самостоятельный проект — уровень программы в дереве и в выгрузке
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b87b12f980
commit
f5ce14fd11
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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` не хранит данных узла).
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user