diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx
index edf618d..528e048 100644
--- a/src/layouts/DocumentPage.tsx
+++ b/src/layouts/DocumentPage.tsx
@@ -54,7 +54,7 @@ export function DocumentPage() {
/>
{broken && }
- {!broken && kind === 'osv' && }
+ {!broken && kind === 'osv' && }
{!broken && kind === 'statement' && }
{kind === 'none' && }
diff --git a/src/widgets/DocumentReview/entryTree.test.ts b/src/widgets/DocumentReview/entryTree.test.ts
index a55e89a..8276241 100644
--- a/src/widgets/DocumentReview/entryTree.test.ts
+++ b/src/widgets/DocumentReview/entryTree.test.ts
@@ -31,4 +31,20 @@ describe('buildEntryTree', () => {
it('ребёнок с неизвестным родителем — в корень', () => {
expect(buildEntryTree([e('x', 'missing', 1)]).map((n) => n.id)).toEqual(['x']);
});
+ it('самоссылка — в корень, без рекурсии', () => {
+ const tree = buildEntryTree([e('a', 'a', 1), e('b', null, 2)]);
+ expect(tree.map((n) => n.id)).toEqual(['a', 'b']);
+ expect(tree[0].children).toBeUndefined();
+ });
+ it('цикл из двух — оба в корень, ничего не теряется', () => {
+ const tree = buildEntryTree([e('a', 'b', 1), e('b', 'a', 2)]);
+ expect(tree.map((n) => n.id)).toEqual(['a', 'b']);
+ expect(tree.every((n) => n.children === undefined)).toBe(true);
+ });
+ it('дубль id — второй экземпляр пропускается', () => {
+ const tree = buildEntryTree([e('a', null, 1), e('a', null, 2), e('c', 'a', 3)]);
+ expect(tree.map((n) => n.id)).toEqual(['a']);
+ expect(tree[0].row_number).toBe(1);
+ expect(tree[0].children?.map((n) => n.id)).toEqual(['c']);
+ });
});
diff --git a/src/widgets/DocumentReview/entryTree.ts b/src/widgets/DocumentReview/entryTree.ts
index 5e3c68e..504d2b3 100644
--- a/src/widgets/DocumentReview/entryTree.ts
+++ b/src/widgets/DocumentReview/entryTree.ts
@@ -2,14 +2,29 @@ import type { EntryOut } from '../../types/api';
export type EntryNode = EntryOut & { key: string; children?: EntryNode[] };
-/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number). */
-export function buildEntryTree(entries: EntryOut[]): EntryNode[] {
+/** Дерево строк ОСВ по parent_entry_id; порядок — как пришёл (по row_number).
+ * Защита от «грязных» данных: дубль id — второй экземпляр пропускается; самоссылка и цикл — такие строки идут в корень. */
+export function buildEntryTree(input: EntryOut[]): EntryNode[] {
+ const seen = new Set();
+ const entries = input.filter((entry) => !seen.has(entry.id) && seen.add(entry.id));
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
+
+ // Строка в цикле, если, идя вверх по родителям, мы вернёмся к ней самой.
+ const inCycle = (start: EntryOut): boolean => {
+ let current = start.parent_entry_id ? byId.get(start.parent_entry_id) : undefined;
+ for (let steps = 0; current && steps <= entries.length; steps++) {
+ if (current.id === start.id) return true;
+ current = current.parent_entry_id ? byId.get(current.parent_entry_id) : undefined;
+ }
+ return false;
+ };
+
const nodes = new Map();
for (const entry of entries) nodes.set(entry.id, { ...entry, key: entry.id });
const roots: EntryNode[] = [];
for (const entry of entries) {
const node = nodes.get(entry.id) as EntryNode;
- const parent = entry.parent_entry_id ? nodes.get(entry.parent_entry_id) : undefined;
+ const parent = entry.parent_entry_id && !inCycle(entry) ? nodes.get(entry.parent_entry_id) : undefined;
if (parent) {
parent.children = [...(parent.children ?? []), node];
} else {