Merge pull request 'fix(web): синхронизация встроенного фронта с arm-web@test (1237105) (#120)' (#122) from feat/120-web-sync-1237105 into main
This commit is contained in:
commit
19b44c4058
@ -138,6 +138,8 @@ export interface LineOut {
|
||||
origin: 'source' | 'template';
|
||||
line_code: string | null;
|
||||
line_name: string | null;
|
||||
// код четырёхзначной строки-родителя у пятизначной подстроки (считает ядро, AURORA-1527); иначе null
|
||||
parent_code: string | null;
|
||||
values: ValueOut[];
|
||||
}
|
||||
export interface FailedCheckOut {
|
||||
|
||||
@ -8,6 +8,8 @@ export const failureText = (f: FailedCheckOut) =>
|
||||
|
||||
type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise<void> };
|
||||
|
||||
// soft_failed — мягкие соотношения §7 (2400, 2500, «сумма подстрок = родитель») и жёсткие в пределах округления;
|
||||
// подтверждение они не блокируют
|
||||
// длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран
|
||||
const failureList = (list: FailedCheckOut[]) => (
|
||||
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
|
||||
@ -45,7 +47,12 @@ export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
|
||||
<Alert type='error' showIcon title={`Нарушения (${checks.failed.length})`} description={failureList(checks.failed)} />
|
||||
)}
|
||||
{checks.soft_failed.length > 0 && (
|
||||
<Alert type='warning' showIcon title='Расхождения в пределах округления' description={failureList(checks.soft_failed)} />
|
||||
<Alert
|
||||
type='warning'
|
||||
showIcon
|
||||
title={`Предупреждения проверок (${checks.soft_failed.length})`}
|
||||
description={failureList(checks.soft_failed)}
|
||||
/>
|
||||
)}
|
||||
{checks.cross_form && (
|
||||
<Alert
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Space, Splitter, message } from 'antd';
|
||||
import { Alert, Button, Space, Splitter, Switch, message } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { acceptChecks, confirmDocument, getStatement, patchStatement } from '../../services/api/documents';
|
||||
@ -12,7 +12,7 @@ import { type HeaderDraft, StatementHeaderBlock } from './StatementHeaderBlock';
|
||||
import { StatementTable } from './StatementTable';
|
||||
import { confirmErrorInfo } from './confirmError';
|
||||
import { reviewActions } from './reviewView';
|
||||
import { applyDraftInput, buildStatementGrid, draftToPatch } from './statementGrid';
|
||||
import { applyDraftInput, buildStatementGrid, draftToPatch, emptySublineKeys } from './statementGrid';
|
||||
|
||||
const errorText = (e: unknown, fallback: string) => (e instanceof ApiError ? e.message : fallback);
|
||||
|
||||
@ -25,6 +25,7 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmFailures, setConfirmFailures] = useState<FailedCheckOut[]>([]);
|
||||
const [confirmMessage, setConfirmMessage] = useState<string | null>(null);
|
||||
const [showEmptySublines, setShowEmptySublines] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@ -40,6 +41,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
}, [load]);
|
||||
|
||||
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
|
||||
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
|
||||
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft) : new Set<string>()), [grid, draft]);
|
||||
const shownRows = useMemo(() => {
|
||||
if (!grid) return [];
|
||||
return showEmptySublines ? grid.rows : grid.rows.filter((row) => !emptySublines.has(row.key));
|
||||
}, [grid, showEmptySublines, emptySublines]);
|
||||
const actions = reviewActions(statement?.document.status ?? 'ERROR', editing);
|
||||
// ячейки редактируются только в режиме правки; принять нарушения можно, пока документ не подтверждён
|
||||
const changeable = statement !== null && statement.document.status !== 'CONFIRMED' && statement.document.status !== 'ERROR';
|
||||
@ -191,10 +198,18 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{emptySublines.size > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch id='show-empty-sublines' size='small' checked={showEmptySublines} onChange={setShowEmptySublines} />
|
||||
<label htmlFor='show-empty-sublines' style={{ cursor: 'pointer' }}>
|
||||
Показать пустые строки детализации ({emptySublines.size})
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
<StatementTable
|
||||
documentType={statement.document.document_type}
|
||||
columns={grid.columns}
|
||||
rows={grid.rows}
|
||||
rows={shownRows}
|
||||
editable={editing && actions.canSave}
|
||||
draft={draft}
|
||||
onDraftChange={(key, text, original) => setDraft((prev) => applyDraftInput(prev, key, text, original, parseAmountInput))}
|
||||
|
||||
@ -33,6 +33,7 @@ function cellTip(value: ValueOut): string | undefined {
|
||||
return value.confidence ? `Уверенность: ${value.confidence}%` : undefined;
|
||||
}
|
||||
|
||||
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
|
||||
const SECTION_BG = '#f2f4f7';
|
||||
const GRAND_BG = '#f9f0ff'; // строки «БАЛАНС»: фиолетовый не занят (серый — разделы, синий — исправлено, оранжевый — внимание)
|
||||
|
||||
@ -61,7 +62,11 @@ export function StatementTable({ documentType, columns, rows, editable, draft, o
|
||||
}),
|
||||
render: (_: unknown, r: LayoutRow) => {
|
||||
if (r.kind === 'section') return r.title;
|
||||
const text = <span style={{ fontWeight: lineWeight(r) }}>{r.displayName || '—'}</span>;
|
||||
const text = (
|
||||
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}>
|
||||
{r.displayName || '—'}
|
||||
</span>
|
||||
);
|
||||
return r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
|
||||
},
|
||||
},
|
||||
|
||||
@ -8,8 +8,10 @@ const row = (code: string | null, name: string | null = `строка ${code}`):
|
||||
origin: 'source',
|
||||
code,
|
||||
name,
|
||||
parentCode: null,
|
||||
cells: {},
|
||||
});
|
||||
const sub = (code: string, parentCode: string | null): GridRow => ({ ...row(code), parentCode });
|
||||
const shape = (rows: ReturnType<typeof formLayout>) =>
|
||||
rows.map((r) => (r.kind === 'section' ? `# ${r.title}` : `${r.row.code}${r.total ? ' (итог)' : ''}`));
|
||||
|
||||
@ -104,3 +106,34 @@ describe('formLayout — строки «БАЛАНС»', () => {
|
||||
expect(layout.some((r) => r.kind === 'line' && r.grand)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formLayout — строки детализации (parent_code)', () => {
|
||||
it('ББ: подстрока сразу под родителем, в порядке кода, с пометкой child; чужой раздел не задет', () => {
|
||||
const rows = [row('1110'), sub('11102', '1110'), sub('11101', '1110'), row('1120'), sub('11201', '1120'), row('1100')];
|
||||
const layout = formLayout('BALANCE', rows);
|
||||
expect(shape(layout)).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1110', '11101', '11102', '1120', '11201', '1100 (итог)']);
|
||||
expect(layout.map((r) => r.kind === 'line' && r.child)).toEqual([false, false, false, true, true, false, true, false]);
|
||||
});
|
||||
it('ББ: подстрока без родителя в форме остаётся в «Прочие строки»', () => {
|
||||
const rows = [row('1150'), sub('11701', '1170')];
|
||||
expect(shape(formLayout('BALANCE', rows))).toEqual(['# АКТИВ', '# I. Внеоборотные активы', '1150', '# Прочие строки', '11701']);
|
||||
});
|
||||
it('ББ: родитель в «Прочих строках» — подстроки под ним', () => {
|
||||
const rows = [row('9990'), sub('99901', '9990')];
|
||||
expect(shape(formLayout('BALANCE', rows))).toEqual(['# Прочие строки', '9990', '99901']);
|
||||
});
|
||||
it('ОФР: подстрока под родителем; без родителя — в конце, как раньше', () => {
|
||||
const rows = [row('2110'), sub('21101', '2110'), row('2120'), sub('21201', '2120'), row('2100'), sub('29901', '2990')];
|
||||
expect(shape(formLayout('INCOME', rows))).toEqual(['2110', '21101', '2120', '21201', '2100 (итог)', '29901']);
|
||||
});
|
||||
it('родитель, сам имеющий parent_code, и самоссылка не теряют строки', () => {
|
||||
const rows = [row('1150'), sub('11501', '11502'), sub('11502', '1150'), sub('1160', '1160')];
|
||||
const codes = formLayout('BALANCE', rows).flatMap((r) => (r.kind === 'line' ? [r.row.code] : []));
|
||||
expect([...codes].sort()).toEqual(['1150', '11501', '11502', '1160']);
|
||||
});
|
||||
it('два ряда с одним кодом родителя: подстроки выводятся один раз', () => {
|
||||
const rows = [row('1110'), row('1110'), sub('11101', '1110')];
|
||||
const codes = formLayout('BALANCE', rows).flatMap((r) => (r.kind === 'line' ? [r.row.code] : []));
|
||||
expect(codes.filter((c) => c === '11101')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,7 +7,17 @@ import type { GridRow } from './statementGrid';
|
||||
|
||||
export type LayoutRow =
|
||||
| { kind: 'section'; key: string; title: string; level: 1 | 2 }
|
||||
| { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; displayName: string; fullName: string | null };
|
||||
| {
|
||||
kind: 'line';
|
||||
key: string;
|
||||
row: GridRow;
|
||||
total: boolean;
|
||||
grand: boolean;
|
||||
/** строка детализации, выведенная под своим родителем (отступ в названии) */
|
||||
child: boolean;
|
||||
displayName: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
type Section = { title: string; hundred: number };
|
||||
|
||||
@ -53,7 +63,7 @@ function stripSectionPrefixes(name: string | null, prefixes: string[]): { displa
|
||||
// строки «БАЛАНС» по активу (1600) и пассиву (1700) выделяются цветом (просьба владельца 09.10.2026)
|
||||
const GRAND_TOTALS = new Set(['1600', '1700']);
|
||||
|
||||
function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow {
|
||||
function lineRow(row: GridRow, prefixes: string[], balance: boolean): Extract<LayoutRow, { kind: 'line' }> {
|
||||
const code = codeNum(row);
|
||||
return {
|
||||
kind: 'line',
|
||||
@ -61,6 +71,7 @@ function lineRow(row: GridRow, prefixes: string[], balance: boolean): LayoutRow
|
||||
row,
|
||||
total: code !== null && isTotal(code),
|
||||
grand: balance && GRAND_TOTALS.has(row.code ?? ''),
|
||||
child: false,
|
||||
...stripSectionPrefixes(row.name, prefixes),
|
||||
};
|
||||
}
|
||||
@ -111,6 +122,33 @@ function incomeLayout(rows: GridRow[]): LayoutRow[] {
|
||||
return [...sorted, ...rows.filter((r) => codeNum(r) === null)].map((r) => lineRow(r, [], false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка детализации (parent_code от ядра) выводится сразу под родителем, по коду; родитель — только обычная строка формы
|
||||
* (parent_code = null) из этого же набора. Без родителя в форме строка остаётся там, где была бы без parent_code.
|
||||
*/
|
||||
export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[] {
|
||||
return type === 'BALANCE' ? balanceLayout(rows) : incomeLayout(rows);
|
||||
const parents = new Set(rows.filter((r) => r.parentCode === null && r.code !== null).map((r) => r.code as string));
|
||||
const children = new Map<string, GridRow[]>();
|
||||
const top: GridRow[] = [];
|
||||
for (const r of rows) {
|
||||
if (r.parentCode !== null && parents.has(r.parentCode)) {
|
||||
children.set(r.parentCode, [...(children.get(r.parentCode) ?? []), r]);
|
||||
} else {
|
||||
top.push(r);
|
||||
}
|
||||
}
|
||||
const base = type === 'BALANCE' ? balanceLayout(top) : incomeLayout(top);
|
||||
if (children.size === 0) return base;
|
||||
const prefixes = type === 'BALANCE' ? BALANCE_PREFIXES : [];
|
||||
const out: LayoutRow[] = [];
|
||||
for (const item of base) {
|
||||
out.push(item);
|
||||
if (item.kind !== 'line' || !item.row.code) continue;
|
||||
const kids = children.get(item.row.code);
|
||||
if (!kids) continue;
|
||||
children.delete(item.row.code); // два ряда с одним кодом родителя: подстроки выводим один раз
|
||||
const sorted = [...kids].sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '') || a.key.localeCompare(b.key));
|
||||
for (const kid of sorted) out.push({ ...lineRow(kid, prefixes, type === 'BALANCE'), child: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StatementOut, ValueOut } from '../../types/api';
|
||||
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch } from './statementGrid';
|
||||
import type { GridRow } from './statementGrid';
|
||||
import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid';
|
||||
|
||||
const v = (id: string | null, column_id: string, column_index: number, extra: Partial<ValueOut> = {}): ValueOut => ({
|
||||
id,
|
||||
@ -107,3 +108,41 @@ describe('applyDraftInput', () => {
|
||||
expect(applyDraftInput({}, 'k', '12,3,4', '1.00', parse)).toEqual({ k: '12,3,4' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent_code и пустые строки детализации', () => {
|
||||
it('buildStatementGrid переносит parent_code', () => {
|
||||
const withChild = {
|
||||
...statement,
|
||||
lines: [{ id: 'l2', origin: 'source', line_code: '11101', line_name: 'п', parent_code: '1110', values: [] }, ...statement.lines],
|
||||
} as unknown as StatementOut;
|
||||
const grid = buildStatementGrid(withChild);
|
||||
expect(grid.rows[0].parentCode).toBe('1110');
|
||||
expect(grid.rows[1].parentCode).toBeNull(); // поля нет в ответе (старый сервер) — не подстрока
|
||||
});
|
||||
|
||||
const cell = (extra: Partial<ValueOut>) => v('v', 'c1', 0, { amount: null, ...extra });
|
||||
const gridRow = (key: string, parentCode: string | null, cells: GridRow['cells']): GridRow => ({
|
||||
key,
|
||||
lineId: key,
|
||||
code: key,
|
||||
name: null,
|
||||
origin: 'source',
|
||||
parentCode,
|
||||
cells,
|
||||
});
|
||||
it('пустая подстрока: все ячейки без суммы и без строки; родитель и нулевая сумма — не пустые', () => {
|
||||
const rows = [
|
||||
gridRow('11101', '1110', { c1: cell({}), c2: cell({}) }),
|
||||
gridRow('11102', '1110', {}),
|
||||
gridRow('11103', '1110', { c1: cell({ amount: '0.00' }) }),
|
||||
gridRow('11104', '1110', { c1: cell({ string_value: 'нет' }) }),
|
||||
gridRow('11105', '1110', { c1: cell({ amount: '5.00' }) }),
|
||||
gridRow('1110', null, { c1: cell({}) }),
|
||||
];
|
||||
expect([...emptySublineKeys(rows, {})]).toEqual(['11101', '11102']);
|
||||
});
|
||||
it('подстрока с правкой в черновике не считается пустой', () => {
|
||||
const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })];
|
||||
expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,8 @@ export type GridRow = {
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
origin: 'source' | 'template';
|
||||
/** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */
|
||||
parentCode: string | null;
|
||||
cells: Record<string, ValueOut | undefined>;
|
||||
};
|
||||
|
||||
@ -25,6 +27,7 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu
|
||||
code: line.line_code,
|
||||
name: line.line_name,
|
||||
origin: line.origin,
|
||||
parentCode: line.parent_code ?? null,
|
||||
cells,
|
||||
};
|
||||
});
|
||||
@ -42,6 +45,20 @@ export function cellFlags(value: ValueOut | undefined): { attention: boolean; mo
|
||||
|
||||
export const cellKey = (rowKeyValue: string, columnId: string) => `${rowKeyValue}:${columnId}`;
|
||||
|
||||
/** Ячейка со значением: сумма (в т. ч. нулевая) или текст; пусто — только null/пустая строка. */
|
||||
const hasValue = (value: ValueOut | undefined) => Boolean(value) && ((value?.amount ?? '') !== '' || (value?.string_value ?? '') !== '');
|
||||
|
||||
/**
|
||||
* Ключи пустых строк детализации (parent_code != null): ни в одной колонке нет значения и нет правки в черновике.
|
||||
* Ноль — значение, такая строка не пуста. Только показ: данные и PATCH не затрагиваются.
|
||||
*/
|
||||
export function emptySublineKeys(rows: GridRow[], draft: Record<string, string>): Set<string> {
|
||||
const edited = new Set(Object.keys(draft).map((key) => key.slice(0, key.lastIndexOf(':'))));
|
||||
return new Set(
|
||||
rows.filter((row) => row.parentCode !== null && !edited.has(row.key) && !Object.values(row.cells).some(hasValue)).map((row) => row.key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Черновик → тело PATCH: строка из БД адресуется line_id, достроенная — line_code (сервер создаст её). */
|
||||
export function draftToPatch(draft: Record<string, string | null>, rows: GridRow[]): CellPatch[] {
|
||||
const byKey = new Map(rows.map((row) => [row.key, row]));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user