Перенесён диапазон arm-web 025233f..1237105 (3 коммита, 8 файлов набора синхронизации) по шагам docs/scripts/sync-web.sh. После переноса web/ и arm-web@test идентичны по набору. Коммиты arm-web: - 54f88e9 feat(DocumentReview): подстроки под родителем, пустые строки детализации свёрнуты (#34, AURORA-1527) - 85e1b86 fix(ChecksPanel): заголовок мягких проверок «Предупреждения проверок (N)» (#34, AURORA-1527) - a4f5481 fix(DocumentReview): label переключателя пустых строк связан через htmlFor (#36, AURORA-1527) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
5.0 KiB
TypeScript
109 lines
5.0 KiB
TypeScript
import type { CellPatch, ColumnOut, StatementOut, ValueOut } from '../../types/api';
|
||
|
||
export type GridRow = {
|
||
key: string;
|
||
lineId: string | null;
|
||
code: string | null;
|
||
name: string | null;
|
||
origin: 'source' | 'template';
|
||
/** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */
|
||
parentCode: string | null;
|
||
cells: Record<string, ValueOut | undefined>;
|
||
};
|
||
|
||
/** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */
|
||
const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`;
|
||
|
||
export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
|
||
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
|
||
const rows = statement.lines.map((line) => {
|
||
const cells: Record<string, ValueOut | undefined> = {};
|
||
for (const value of line.values) {
|
||
cells[value.column_id] = value;
|
||
}
|
||
return {
|
||
key: rowKey(line.id, line.line_code),
|
||
lineId: line.id,
|
||
code: line.line_code,
|
||
name: line.line_name,
|
||
origin: line.origin,
|
||
parentCode: line.parent_code ?? null,
|
||
cells,
|
||
};
|
||
});
|
||
return { columns, rows };
|
||
}
|
||
|
||
/** Уверенность — строка Decimal «0..100»; сравнение с 100 — это не сумма, float здесь безопасен. */
|
||
export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean } {
|
||
if (!value) {
|
||
return { attention: false, modified: false };
|
||
}
|
||
const lowConfidence = value.confidence !== null && Number(value.confidence) < 100;
|
||
return { attention: lowConfidence || value.is_recognized === false, modified: value.is_modified };
|
||
}
|
||
|
||
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]));
|
||
return Object.entries(draft).map(([key, amount]) => {
|
||
const sep = key.lastIndexOf(':');
|
||
const row = byKey.get(key.slice(0, sep));
|
||
const column_id = key.slice(sep + 1);
|
||
if (!row) {
|
||
throw new Error(`строка черновика ${key} не найдена в форме`);
|
||
}
|
||
return row.lineId ? { line_id: row.lineId, column_id, amount } : { line_code: row.code ?? '', column_id, amount };
|
||
});
|
||
}
|
||
|
||
/** Каноничный вид суммы для сравнения строк: без хвостовых нулей дроби и лишних ведущих нулей. */
|
||
function normalizeAmount(value: string | null): string | null {
|
||
if (value === null) {
|
||
return null;
|
||
}
|
||
const match = /^(-?)(\d+)(?:\.(\d*))?$/.exec(value);
|
||
if (!match) {
|
||
return value;
|
||
}
|
||
const int = match[2].replace(/^0+(?=\d)/, '');
|
||
const frac = (match[3] ?? '').replace(/0+$/, '');
|
||
const body = frac ? `${int}.${frac}` : int;
|
||
return body === '0' ? '0' : `${match[1]}${body}`;
|
||
}
|
||
|
||
/**
|
||
* Обновить черновик после ввода в ячейку: если значение совпало с исходным — ключ убирается
|
||
* (в PATCH уходят только реальные изменения). Неверный формат остаётся в черновике (подсвечивается и блокирует сохранение).
|
||
*/
|
||
export function applyDraftInput(
|
||
draft: Record<string, string>,
|
||
key: string,
|
||
text: string,
|
||
originalAmount: string | null,
|
||
parse: (input: string) => { ok: true; value: string | null } | { ok: false },
|
||
): Record<string, string> {
|
||
const parsed = parse(text);
|
||
if (parsed.ok && normalizeAmount(parsed.value) === normalizeAmount(originalAmount)) {
|
||
const { [key]: _removed, ...rest } = draft;
|
||
return rest;
|
||
}
|
||
return { ...draft, [key]: text };
|
||
}
|