Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (#43, arm-ka-docs#116) #44

Merged
aep merged 22 commits from feat/43-statements-v2 into test 2026-10-18 19:09:18 +03:00
9 changed files with 241 additions and 28 deletions
Showing only changes of commit a779d2a04a - Show all commits

View File

@ -0,0 +1,60 @@
import { Button, Input, Tooltip } from 'antd';
import type { ColumnOut, IssueOut } from '../../types/api';
import { SeverityIcon } from './IssuesPanel';
import { columnDateEditable } from './editRules';
import { worstSeverity } from './issuePlaces';
import { columnTitle } from './reviewView';
type Props = {
col: ColumnOut;
issues: IssueOut[];
editing: boolean;
/** введённая, но не сохранённая дата колонки */
draftDate?: string;
error?: boolean;
onDateChange?: (columnId: string, text: string) => void;
/** «Подтвердить дату»: кладёт в черновик текущую дату колонки */
onConfirmDate?: (col: ColumnOut) => void;
};
/** Заголовок колонки: подпись, значок предупреждения и (в режиме правки) поле даты ГГГГ-ММ-ДД с подтверждением. */
export function ColumnHeader({ col, issues, editing, draftDate, error = false, onDateChange, onConfirmDate }: Props) {
const worst = worstSeverity(issues);
const title = (
<span>
{worst && <SeverityIcon severity={worst} style={{ marginRight: 4 }} />}
{columnTitle(col)}
</span>
);
const withTip = worst ? (
<Tooltip
title={issues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
{title}
</Tooltip>
) : (
title
);
const { editable, canConfirm } = columnDateEditable(col, issues);
if (!editing || !editable) return withTip;
const pending = canConfirm && draftDate !== undefined && draftDate === col.period_date;
return (
<div>
{withTip}
<Input
size='small'
status={error ? 'error' : undefined}
placeholder='ГГГГ-ММ-ДД'
value={draftDate ?? col.period_date ?? ''}
onChange={(e) => onDateChange?.(col.id, e.target.value)}
style={{ marginTop: 4, fontWeight: 400 }}
/>
{canConfirm && (
<Button size='small' style={{ marginTop: 4 }} disabled={pending} onClick={() => onConfirmDate?.(col)}>
{pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
</Button>
)}
</div>
);
}

View File

@ -73,8 +73,8 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
onChange={(e) => onDraftChange(r.field, e.target.value)}
/>
{r.canConfirm && onConfirmDate && (
<Button size='small' style={{ marginTop: 4 }} onClick={onConfirmDate}>
Подтвердить дату
<Button size='small' style={{ marginTop: 4 }} disabled={draft.confirm_report_date === true} onClick={onConfirmDate}>
{draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
</Button>
)}
</>

View File

@ -273,6 +273,20 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
}
onLineMenu={(row, key) => setDraft((prev) => ({ ...prev, lines: applyLineMenu(row, key, prev.lines) }))}
errorKeys={errorKeys}
fullDraft={draft}
onConfirmToggle={(key, state) =>
setDraft((prev) => {
const confirms = { ...prev.confirms };
// повторный клик по нажатой кнопке убирает подтверждение из черновика
if (state === 'pending-confirm' || state === 'pending-unconfirm') delete confirms[key];
else confirms[key] = state === 'confirm';
return { ...prev, confirms };
})
}
onColumnDateChange={(columnId, text) => setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [columnId]: text } }))}
onColumnDateConfirm={(col) =>
col.period_date && setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [col.id]: col.period_date as string } }))
}
onDraftChange={(key, text, original) =>
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
}

View File

@ -1,17 +1,18 @@
import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut, IssueOut } from '../../types/api';
import type { ColumnOut } from '../../types/api';
import { CodeCell } from './CodeCell';
import { ColumnHeader } from './ColumnHeader';
import { SeverityIcon } from './IssuesPanel';
import { LineMenuButton } from './LineMenuButton';
import { ValueCell } from './ValueCell';
import { COLORS, rowBackground, valueBackground } from './cellView';
import type { CodeOption } from './codeSuggestions';
import { type CellConfirmState, cellConfirmState } from './editRules';
import type { LayoutRow } from './formLayout';
import { type IssuePlaces, worstSeverity } from './issuePlaces';
import type { LineMenuKey } from './lineMenu';
import { columnTitle } from './reviewView';
import type { LineAction } from './statementDraft';
import type { LineAction, StatementDraft } from './statementDraft';
import { type GridRow, cellKey } from './statementGrid';
type Props = {
@ -32,6 +33,11 @@ type Props = {
lineErrors?: Record<string, string>;
onLineCodeChange?: (row: GridRow, code: string) => void;
onLineMenu?: (row: GridRow, key: LineMenuKey) => void;
/** весь черновик: подтверждения ячеек и даты колонок (суммы — в `draft` выше) */
fullDraft?: StatementDraft;
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
onColumnDateChange?: (columnId: string, text: string) => void;
onColumnDateConfirm?: (col: ColumnOut) => void;
};
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
@ -45,22 +51,6 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !==
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) {
const worst = worstSeverity(issues);
if (!worst) return <>{columnTitle(col)}</>;
return (
<Tooltip
title={issues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
<span>
<SeverityIcon severity={worst} style={{ marginRight: 4 }} />
{columnTitle(col)}
</span>
</Tooltip>
);
}
export function StatementTable({
columns,
layout,
@ -75,6 +65,10 @@ export function StatementTable({
lineErrors = {},
onLineCodeChange,
onLineMenu,
fullDraft,
onConfirmToggle,
onColumnDateChange,
onColumnDateConfirm,
}: Props) {
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
@ -156,7 +150,17 @@ export function StatementTable({
},
...columns.map((col) => ({
key: col.id,
title: <ColumnTitle col={col} issues={places.byColumn.get(col.id) ?? []} />,
title: (
<ColumnHeader
col={col}
issues={places.byColumn.get(col.id) ?? []}
editing={editable}
draftDate={fullDraft?.columns[col.id]}
error={errorKeys?.has(col.id)}
onDateChange={onColumnDateChange}
onConfirmDate={onColumnDateConfirm}
/>
),
width: 112,
align: 'right' as const,
onCell: (r: LayoutRow) =>
@ -184,6 +188,8 @@ export function StatementTable({
onDraftChange={onDraftChange}
hasError={errorKeys?.has(draftKey)}
issues={places.byCell.get(draftKey)}
confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null}
onConfirmToggle={onConfirmToggle}
/>
);
},

View File

@ -1,9 +1,10 @@
import { CheckOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
import { Button, Input, Tooltip } from 'antd';
import type { IssueOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money';
import { SeverityIcon } from './IssuesPanel';
import { cellTipLines } from './cellView';
import type { CellConfirmState } from './editRules';
import { worstSeverity } from './issuePlaces';
type Props = {
@ -17,10 +18,30 @@ type Props = {
issues?: IssueOut[];
/** ошибка черновика, найденная при сохранении */
hasError?: boolean;
/** какая кнопка подтверждения нужна ячейке (editRules.cellConfirmState) и обработчик её нажатия */
confirmState?: CellConfirmState;
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
};
const CONFIRM_TITLE = {
confirm: 'Подтвердить значение',
unconfirm: 'Снять подтверждение',
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
} as const;
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [], hasError = false }: Props) {
export function ValueCell({
value,
editable,
draftKey,
draft,
onDraftChange,
issues = [],
hasError = false,
confirmState = null,
onConfirmToggle,
}: Props) {
const lines = cellTipLines(value, issues);
const worst = worstSeverity(issues);
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
@ -39,9 +60,10 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
}
const text = draft[draftKey] ?? value.amount ?? '';
const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok);
const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm';
return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, width: '100%' }}>
{corner}
<Input
size='small'
@ -49,8 +71,21 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
value={text}
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
style={{ textAlign: 'right' }}
// значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст
style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }}
/>
{confirmState && (
<Tooltip title={CONFIRM_TITLE[confirmState]}>
<Button
size='small'
type={pending ? 'primary' : 'default'}
aria-pressed={pending}
aria-label={CONFIRM_TITLE[confirmState]}
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
/>
</Tooltip>
)}
</span>
</Tooltip>
);

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import type { IssueOut, StatementOut } from '../../types/api';
import statementV2Json from './__fixtures__/statement-v2.json';
import { cellConfirmState, columnDateEditable } from './editRules';
import { emptyDraft } from './statementDraft';
import { buildStatementGrid, cellKey } from './statementGrid';
const statement = statementV2Json as StatementOut;
const { rows, columns } = buildStatementGrid(statement);
const col = (id: string) => columns.find((c) => c.id === id) as (typeof columns)[number];
const issue = (code: string) => statement.issues.find((i) => i.code === code) as IssueOut;
const rowOf = (key: string) => rows.find((r) => r.key === key) as (typeof rows)[number];
describe('columnDateEditable', () => {
it('колонка без даты с записью — правится, подтверждать нечего', () => {
expect(columnDateEditable(col('c2'), [issue('COLUMN_WITHOUT_DATE')])).toEqual({ editable: true, canConfirm: false });
});
it('колонка с датой и без записей — не правится', () => {
expect(columnDateEditable(col('c1'), [])).toEqual({ editable: false, canConfirm: false });
});
it('колонка с датой и COLUMN_DATE_UNCERTAIN — правится и подтверждается', () => {
const uncertain = { ...issue('COLUMN_WITHOUT_DATE'), code: 'COLUMN_DATE_UNCERTAIN' } as IssueOut;
expect(columnDateEditable(col('c1'), [uncertain])).toEqual({ editable: true, canConfirm: true });
});
it('посторонняя запись колонки не открывает правку', () => {
expect(columnDateEditable(col('c1'), [issue('RELATION_FAILED')])).toEqual({ editable: false, canConfirm: false });
});
});
describe('cellConfirmState', () => {
const row = rowOf('l-u7');
const value = row.cells.c1;
const key = cellKey('l-u7', 'c1');
it('needs_review → confirm', () => {
expect(cellConfirmState(row, value, emptyDraft(), key)).toBe('confirm');
});
it('подтверждение в черновике → pending-confirm', () => {
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: true } }, key)).toBe('pending-confirm');
});
it('снятие в черновике → pending-unconfirm', () => {
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: false } }, key)).toBe('pending-unconfirm');
});
it('сумма в черновике → null', () => {
expect(cellConfirmState(row, value, { ...emptyDraft(), amounts: { [key]: '7' } }, key)).toBeNull();
});
it('шаблонная строка (нет id) → null', () => {
const tpl = rowOf('code-1250');
expect(cellConfirmState(tpl, tpl.cells.c1, emptyDraft(), cellKey('code-1250', 'c1'))).toBeNull();
});
it('значение без id → null', () => {
expect(cellConfirmState(row, value && { ...value, id: null }, emptyDraft(), key)).toBeNull();
});
it('is_confirmed → unconfirm', () => {
expect(cellConfirmState(row, value && { ...value, needs_review: false, is_confirmed: true }, emptyDraft(), key)).toBe('unconfirm');
});
it('обычная ячейка → null', () => {
expect(cellConfirmState(row, value && { ...value, needs_review: false }, emptyDraft(), key)).toBeNull();
});
});

View File

@ -0,0 +1,25 @@
// Правила правки: когда у колонки можно править дату и когда у ячейки доступно подтверждение (P6-6, P6-11).
import type { ColumnOut, IssueOut, ValueOut } from '../../types/api';
import type { StatementDraft } from './statementDraft';
import type { GridRow } from './statementGrid';
/** Записи issues, при которых аналитик правит или подтверждает дату колонки. */
export const COLUMN_DATE_CODES: ReadonlySet<string> = new Set(['COLUMN_WITHOUT_DATE', 'COLUMN_DATE_UNCERTAIN']);
export function columnDateEditable(col: ColumnOut, columnIssues: IssueOut[]): { editable: boolean; canConfirm: boolean } {
const flagged = columnIssues.some((issue) => COLUMN_DATE_CODES.has(issue.code));
const editable = (col.period_kind === 'none' && col.period_date === null) || flagged;
return { editable, canConfirm: editable && col.period_date !== null && flagged };
}
export type CellConfirmState = 'confirm' | 'unconfirm' | 'pending-confirm' | 'pending-unconfirm' | null;
/** Какая кнопка подтверждения нужна ячейке. Нужны оба `id` (у достроенных ядро ответит UNKNOWN_CELL); сумма в черновике подтверждение отменяет. */
export function cellConfirmState(row: GridRow, value: ValueOut | undefined, draft: StatementDraft, key: string): CellConfirmState {
if (!row.lineId || !value || value.id === null) return null;
if (key in draft.amounts) return null;
if (key in draft.confirms) return draft.confirms[key] ? 'pending-confirm' : 'pending-unconfirm';
if (value.needs_review) return 'confirm';
if (value.is_confirmed) return 'unconfirm';
return null;
}

View File

@ -55,6 +55,19 @@ describe('подтверждение ячейки', () => {
});
});
describe('подтверждение ячейки без id значения', () => {
it('пропускается молча (P6-6)', () => {
const row = rows.find((r) => r.key === 'l-u7');
if (!row?.cells.c1) throw new Error('нет ячейки в фикстуре');
const noId = { ...row, cells: { ...row.cells, c1: { ...row.cells.c1, id: null } } };
const result = buildStatementPatch(draftOf({ confirms: { [cellKey('l-u7', 'c1')]: true } }), {
rows: rows.map((r) => (r === row ? noId : r)),
header: statement.header,
});
expect(result).toEqual({ ok: true, body: null });
});
});
describe('действия строки', () => {
it('код, расшифровка, подтверждение кода', () => {
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '1250' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '1250' }]);

View File

@ -71,7 +71,8 @@ export function buildStatementPatch(
if (key in draft.amounts) continue;
const { rowKey, columnId } = splitKey(key);
const row = byKey.get(rowKey);
if (row?.lineId) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
// у достроенной ячейки (нет id значения) подтверждать нечего: ядро ответило бы UNKNOWN_CELL — пропускаем молча
if (row?.lineId && row.cells[columnId]?.id) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
}
// lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8)