Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
132 lines
5.6 KiB
TypeScript
132 lines
5.6 KiB
TypeScript
import type { ColumnOut, LineStatus, StatementOut, ValueOut } from '../../types/api';
|
||
import type { LineAction } from './statementDraft';
|
||
|
||
export type GridRow = {
|
||
key: string;
|
||
lineId: string | null;
|
||
code: string | null;
|
||
/** line_name — наименование из файла */
|
||
name: string | null;
|
||
/** справочное имя строки по коду редакции */
|
||
referenceName: string | null;
|
||
origin: 'source' | 'template';
|
||
/** Родитель строки детализации — отдаёт ядро (`parent_code`); своего правила на фронте нет. */
|
||
parentCode: string | null;
|
||
lineStatus: LineStatus;
|
||
/** порядковый номер строки в файле; null у достроенной и созданной правкой */
|
||
rowIndex: number | null;
|
||
parentRowIndex: number | null;
|
||
/** код как распознан в файле (например «ИЧ,250»), даже если `code` не распознан */
|
||
codeText: string | null;
|
||
codeConfidence: string | null;
|
||
needsReview: boolean;
|
||
isConfirmed: boolean;
|
||
codeModified: boolean;
|
||
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,
|
||
referenceName: line.reference_name,
|
||
origin: line.origin,
|
||
parentCode: line.parent_code ?? null,
|
||
lineStatus: line.line_status,
|
||
rowIndex: line.row_index,
|
||
parentRowIndex: line.parent_row_index,
|
||
codeText: line.line_code_text,
|
||
codeConfidence: line.line_code_confidence,
|
||
needsReview: line.needs_review,
|
||
isConfirmed: line.is_confirmed,
|
||
codeModified: line.line_code_modified,
|
||
cells,
|
||
};
|
||
});
|
||
return { columns, rows };
|
||
}
|
||
|
||
/**
|
||
* Флаги ячейки: внимание — только по `needs_review` (уверенность решает ядро, сама по себе не подсвечивает);
|
||
* подтверждённая ячейка не меняет уверенность, это отдельный флаг.
|
||
*/
|
||
export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean; confirmed: boolean } {
|
||
if (!value) {
|
||
return { attention: false, modified: false, confirmed: false };
|
||
}
|
||
return { attention: value.needs_review, modified: value.is_modified, confirmed: value.is_confirmed };
|
||
}
|
||
|
||
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>,
|
||
lineActions: Record<string, LineAction> = {},
|
||
): 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) &&
|
||
!(row.lineId !== null && row.lineId in lineActions) &&
|
||
!Object.values(row.cells).some(hasValue),
|
||
)
|
||
.map((row) => row.key),
|
||
);
|
||
}
|
||
|
||
/** Каноничный вид суммы для сравнения строк: без хвостовых нулей дроби и лишних ведущих нулей. */
|
||
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 };
|
||
}
|