From b8a7782eaeab56725fd51a076d3dc7729061b564 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:08:35 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=BA=D0=BD=D0=B0=20=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Состояние окна «Корректировка операции», проверки ввода, живой контроль (исходные суммы и распределение), кандидаты «Добавить строку ОСВ» и черновик в localStorage. Деньги — строки, сложение через sumAmounts, ввод через parseAmountInput; процент — percentOfSmaller из mockCalc. Co-Authored-By: Claude Opus 5.5 --- .../changes/corrections-table-ui/design.md | 3 + .../CorrectionTable/draftStorage.test.ts | 96 ++++++++++ .../widgets/CorrectionTable/draftStorage.ts | 79 ++++++++ .../CorrectionTable/editorModel.test.ts | 174 ++++++++++++++++++ .../widgets/CorrectionTable/editorModel.ts | 159 ++++++++++++++++ 5 files changed, 511 insertions(+) create mode 100644 web/src/widgets/CorrectionTable/draftStorage.test.ts create mode 100644 web/src/widgets/CorrectionTable/draftStorage.ts create mode 100644 web/src/widgets/CorrectionTable/editorModel.test.ts create mode 100644 web/src/widgets/CorrectionTable/editorModel.ts diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index cb22064..1a2afcf 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -182,3 +182,6 @@ | 28.10.2026 | R-1: список правил в плане — ориентир; при расхождении с api/seed/core/elimination_rules_v1.yaml (в плане статья 1230/1510 встречается дважды) номера и статьи берутся из yaml, тесты плана про правила 1, 3, 6, 7 остаются верными | seed — источник истины | поправка mockData и тестов | | 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения | | 28.10.2026 | R-3: в `mockData` сверх дизайна добавлены агрегат правила 6 «ООО "БСК"» — «ПМП ООО» (310000.00 / 300000.00) и агрегат правила 4 «Агро-Холдинг» без второй стороны (ONE_SIDED); `status` в фейке — ONE_SIDED / DISCREPANCY / AUTO, MANUAL не выдаётся | тест фильтра `ооо "бск"` из плана требует агрегат с таким ЮЛ; ONE_SIDED нужен для проверки отображения | поправка данных фейка, на прод не влияет | +| 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` | +| 28.10.2026 | R-5: `editorModel` переиспользует `percentOfSmaller`/`toCents`/`fromCents` из `mockCalc.ts`; вынос в `utils/money.ts` отложен | не раздувать задачу; поведение одинаково | при подключении API `mockCalc` может уйти — функции придётся переехать | +| 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк | diff --git a/web/src/widgets/CorrectionTable/draftStorage.test.ts b/web/src/widgets/CorrectionTable/draftStorage.test.ts new file mode 100644 index 0000000..5f87b0f --- /dev/null +++ b/web/src/widgets/CorrectionTable/draftStorage.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { CorrectionAggregateOut } from '../../types/corrections'; +import { type Draft, clearDraft, draftKey, isStale, loadDraft, saveDraft } from './draftStorage'; +import type { EditorState } from './editorModel'; +import { makeMockTable } from './mockData'; + +const mapStorage = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => void m.set(k, v), + removeItem: (k: string) => void m.delete(k), + raw: m, + }; +}; + +const state: EditorState = { + debit: [{ line_code: '1170', amount: '29742550.00' }], + credit: [{ line_code: '1410', amount: '29242550.00' }], + comment: 'сверка', +}; +const draft = (baseVersionNo: number | null): Draft => ({ state, savedAt: '2026-10-28T10:00:00.000Z', baseVersionNo }); + +describe('draftKey', () => { + it('формат ключа', () => { + expect(draftKey('u1', 'v1', 'r3|c-pmp|c-rsg')).toBe('armka:correction-draft:u1:v1:r3|c-pmp|c-rsg'); + }); +}); + +describe('saveDraft / loadDraft / clearDraft', () => { + it('сохранённый черновик читается обратно', () => { + const s = mapStorage(); + saveDraft('k', draft(2), s); + expect(loadDraft('k', s)).toEqual(draft(2)); + }); + + it('очистка удаляет черновик', () => { + const s = mapStorage(); + saveDraft('k', draft(null), s); + clearDraft('k', s); + expect(loadDraft('k', s)).toBeNull(); + }); + + it('старый или чужой формат — null', () => { + const s = mapStorage(); + s.setItem('k', '{"x":1}'); + expect(loadDraft('k', s)).toBeNull(); + }); + + it('испорченный JSON — null', () => { + const s = mapStorage(); + s.setItem('k', '{не json'); + expect(loadDraft('k', s)).toBeNull(); + }); + + it('хранилище бросает исключение — null без ошибки', () => { + const broken = { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + removeItem: () => { + throw new Error('denied'); + }, + }; + expect(() => saveDraft('k', draft(null), broken)).not.toThrow(); + expect(loadDraft('k', broken)).toBeNull(); + expect(() => clearDraft('k', broken)).not.toThrow(); + }); + + it('хранилища нет (null) — запись молча пропускается, чтение — null', () => { + expect(() => saveDraft('k', draft(null), null)).not.toThrow(); + expect(loadDraft('k', null)).toBeNull(); + }); +}); + +describe('isStale', () => { + const aggregate = (versionNo: number | null): CorrectionAggregateOut => { + const a = makeMockTable().aggregates[0]; + if (!a.correction) throw new Error('у фейка ЭПТ — ИЛС должна быть корректировка'); + a.correction = versionNo === null ? null : { ...a.correction, version_no: versionNo }; + return a; + }; + + it('версия в черновике 2, в таблице 3 — устарел', () => { + expect(isStale(draft(2), aggregate(3))).toBe(true); + }); + + it('совпадают — не устарел; нет корректировки и в черновике null — не устарел', () => { + expect(isStale(draft(3), aggregate(3))).toBe(false); + expect(isStale(draft(null), aggregate(null))).toBe(false); + expect(isStale(draft(null), aggregate(1))).toBe(true); + }); +}); diff --git a/web/src/widgets/CorrectionTable/draftStorage.ts b/web/src/widgets/CorrectionTable/draftStorage.ts new file mode 100644 index 0000000..e290bf2 --- /dev/null +++ b/web/src/widgets/CorrectionTable/draftStorage.ts @@ -0,0 +1,79 @@ +// Черновик окна корректировки в localStorage: per-viewer удобство. Любой сбой хранилища не ломает окно — работаем без черновика. +import type { CorrectionAggregateOut } from '../../types/corrections'; +import type { EditorState } from './editorModel'; + +export type Draft = { state: EditorState; savedAt: string; baseVersionNo: number | null }; + +export type DraftStorage = Pick; + +/** Ключ: пользователь + версия + агрегат. */ +export function draftKey(userId: string, versionId: string, aggregateKey: string): string { + return `armka:correction-draft:${userId}:${versionId}:${aggregateKey}`; +} + +/** localStorage, если он доступен; в приватном режиме и без хранилища — null. */ +function defaultStorage(): DraftStorage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +function isLine(value: unknown): value is { line_code: string; amount: string } { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return typeof v.line_code === 'string' && typeof v.amount === 'string'; +} + +function isDraft(value: unknown): value is Draft { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + if (typeof v.savedAt !== 'string') return false; + if (v.baseVersionNo !== null && typeof v.baseVersionNo !== 'number') return false; + const s = v.state as Record | null; + if (typeof s !== 'object' || s === null) return false; + return ( + Array.isArray(s.debit) && + s.debit.every(isLine) && + Array.isArray(s.credit) && + s.credit.every(isLine) && + typeof s.comment === 'string' + ); +} + +export function saveDraft(key: string, draft: Draft, storage: DraftStorage | null = defaultStorage()): void { + if (!storage) return; + try { + storage.setItem(key, JSON.stringify(draft)); + } catch { + // Хранилище недоступно или переполнено: окно работает без черновика. + } +} + +/** Старый или чужой формат, испорченный JSON и сбой хранилища — `null`. */ +export function loadDraft(key: string, storage: DraftStorage | null = defaultStorage()): Draft | null { + if (!storage) return null; + try { + const raw = storage.getItem(key); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + return isDraft(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function clearDraft(key: string, storage: DraftStorage | null = defaultStorage()): void { + if (!storage) return; + try { + storage.removeItem(key); + } catch { + // Нечего удалять, если хранилище недоступно. + } +} + +/** Черновик начат от другой редакции корректировки (или от её отсутствия). */ +export function isStale(draft: Draft, a: CorrectionAggregateOut): boolean { + return draft.baseVersionNo !== (a.correction?.version_no ?? null); +} diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts new file mode 100644 index 0000000..7931833 --- /dev/null +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import type { CorrectionAggregateOut, CorrectionTableOut, UnallocatedEntryOut } from '../../types/corrections'; +import type { EditorState } from './editorModel'; +import { + initialEditorState, + isDirty, + liveControl, + pickerCandidates, + toCorrectionIn, + validateLines, +} from './editorModel'; +import { makeMockTable } from './mockData'; +import { aggregateKey } from './tableModel'; + +const table = () => makeMockTable(); +const findBy = (t: CorrectionTableOut, name: string): CorrectionAggregateOut => { + const a = t.aggregates.find((x) => x.company_1.name.includes(name)); + if (!a) throw new Error(`нет агрегата ${name}`); + return a; +}; + +const single = (amount: string): EditorState => ({ debit: [{ line_code: '1170', amount }], credit: [], comment: '' }); + +describe('initialEditorState', () => { + it('агрегат правила 3 без корректировки — по одной статье на сторону с суммами сторон', () => { + const a = findBy(table(), 'Пензенский Мир Продуктов'); + const s = initialEditorState(a); + expect(s.debit).toEqual([{ line_code: '1170', amount: '29742550.00' }]); + expect(s.credit).toEqual([{ line_code: '1410', amount: '29242550.00' }]); + expect(s.comment).toBe(''); + }); + + it('агрегат правила 1 без корректировки — первый код составной статьи', () => { + const base = findBy(table(), 'Пензенский Мир Продуктов'); + const a: CorrectionAggregateOut = { + ...base, + rule_id: 'r1', + rule_no: 1, + side_2: { ...base.side_2, article_line: '1450/1550' }, + }; + const s = initialEditorState(a); + expect(s.credit[0].line_code).toBe('1450'); + }); + + it('есть корректировка — её статьи и суммы как в API', () => { + const a = findBy(table(), 'ЭПТ'); + const s = initialEditorState(a); + expect(s.debit).toEqual([{ line_code: '1230', amount: '1000000.00' }]); + expect(s.credit).toEqual([{ line_code: '1520', amount: '202320.00' }]); + expect(s.comment).toBe('Требуется сверка с контрагентом'); + }); +}); + +describe('validateLines', () => { + it.each(['1 000 000,5', '1000000,567', '-5', ''])('сумма %j — ошибка у amount', (amount) => { + const r = validateLines(single(amount)); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.errors['debit.0.amount']).toBeTruthy(); + }); + + it('сумма 500000,5 → строка 500000.50 в body', () => { + const r = validateLines(single('500000,5')); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.body.debit).toEqual([{ line_code: '1170', amount: '500000.50' }]); + expect(r.body.credit).toEqual([]); + }); + + it('обе стороны пустые — ошибка «Добавьте статью»', () => { + const r = validateLines({ debit: [], credit: [], comment: '' }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(Object.values(r.errors)).toContain('Добавьте статью'); + }); + + it('одна пустая сторона — ок; код — ровно 4 цифры', () => { + expect(validateLines({ debit: [], credit: [{ line_code: '1410', amount: '1' }], comment: '' }).ok).toBe(true); + const r = validateLines({ debit: [{ line_code: '117', amount: '1' }], credit: [], comment: '' }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.errors['debit.0.line_code']).toBeTruthy(); + }); +}); + +describe('liveControl', () => { + it('исходные суммы агрегата ЭПТ — ИЛС: процент 394.27, красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, initialEditorState(a)); + expect(c.sourceDiff.percent).toBe('394.27'); + expect(c.sourceDiff.red).toBe(true); + }); + + it('распределение Дт 1000000.00 и Кт 1000000.00 — разница 0.00, не красный', () => { + const a = findBy(table(), 'ЭПТ'); + const s: EditorState = { + debit: [{ line_code: '1230', amount: '1000000.00' }], + credit: [{ line_code: '1520', amount: '1000000.00' }], + comment: '', + }; + const c = liveControl(a, s); + expect(c.allocationDiff.difference).toBe('0.00'); + expect(c.allocationDiff.red).toBe(false); + }); + + it('одна сторона распределения пуста при ненулевой другой — красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { debit: [{ line_code: '1230', amount: '5.00' }], credit: [], comment: '' }); + expect(c.allocationDiff.red).toBe(true); + }); +}); + +describe('isDirty', () => { + it('одинаковые состояния — не грязно; правка комментария — грязно', () => { + const a = findBy(table(), 'Пензенский Мир Продуктов'); + const init = initialEditorState(a); + expect(isDirty(init, initialEditorState(a))).toBe(false); + expect(isDirty(init, { ...init, comment: 'x' })).toBe(true); + }); +}); + +describe('toCorrectionIn', () => { + it('статус APPLIED и expected_version_no из аргумента', () => { + const r = validateLines(single('1.5')); + if (!r.ok) throw new Error('ожидалось ok'); + expect(toCorrectionIn(r.body, 3)).toEqual({ ...r.body, status: 'APPLIED', expected_version_no: 3 }); + expect(toCorrectionIn(r.body, null).expected_version_no).toBeNull(); + }); +}); + +describe('pickerCandidates', () => { + const entry = (id: string, owner: { id: string; name: string }, cp: { id: string; name: string }, side: 'D' | 'K', extra: Partial = {}): UnallocatedEntryOut => ({ + entry_ids: [id], + owner, + counterparty: cp, + account_code: '60.01', + side, + amount: '100.00', + candidate_rule_ids: ['r6'], + reason: 'MULTIPLE_RULES', + excluded: false, + ...extra, + }); + + it('пара ПМП — Бековский комбинат, правило 6, сторона Кт — только кредитовые строки комбината на ПМП', () => { + const t = table(); + const pmp = { id: 'c-pmp', name: 'Пензенский Мир Продуктов' }; + const bsk = { id: 'c-bsk', name: 'БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО' }; + const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), company_1: pmp, company_2: bsk }; + // Фикстуры строк под эту пару: в mockData такой нераспределённой строки нет. + t.unallocated.push( + entry('ok', bsk, pmp, 'K'), + entry('wrong-side', bsk, pmp, 'D'), + entry('wrong-owner', pmp, bsk, 'K'), + entry('wrong-rule', bsk, pmp, 'K', { candidate_rule_ids: ['r7'] }), + entry('excluded', bsk, pmp, 'K', { excluded: true }), + ); + expect(pickerCandidates(t, a, 2).map((u) => u.entry_ids[0])).toEqual(['ok']); + }); + + it('сторона Дт — владелец company_1, противоположная сторона правила не подходит', () => { + const t = table(); + const pmp = { id: 'c-pmp', name: 'Пензенский Мир Продуктов' }; + const bsk = { id: 'c-bsk', name: 'БЕКОВСКИЙ САХАРНЫЙ КОМБИНАТ ООО' }; + const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), company_1: pmp, company_2: bsk }; + t.unallocated.push(entry('d-ok', pmp, bsk, 'D'), entry('d-bad', pmp, bsk, 'K')); + expect(pickerCandidates(t, a, 1).map((u) => u.entry_ids[0])).toEqual(['d-ok']); + }); + + it('ключ агрегата не меняется от состояния редактора', () => { + const a = findBy(table(), 'ЭПТ'); + expect(aggregateKey(a)).toBe('r6|c-ept|c-ils'); + }); +}); diff --git a/web/src/widgets/CorrectionTable/editorModel.ts b/web/src/widgets/CorrectionTable/editorModel.ts new file mode 100644 index 0000000..84e4676 --- /dev/null +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -0,0 +1,159 @@ +// Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ». +// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, процент и BigInt-копейки — из mockCalc. +import type { + CorrectionAggregateOut, + CorrectionIn, + CorrectionLineIn, + CorrectionTableOut, + Percent, + Side, + UnallocatedEntryOut, +} from '../../types/corrections'; +import { parseAmountInput, sumAmounts } from '../../utils/money'; +import { fromCents, percentOfSmaller, toCents } from './mockCalc'; +import { articleCodes } from './tableModel'; + +export type EditLine = { line_code: string; amount: string }; +export type EditorState = { debit: EditLine[]; credit: EditLine[]; comment: string }; + +export type Diff = { left: string | null; right: string | null; difference: string | null; percent: string | null; red: boolean }; + +export type LinesValidation = + | { ok: true; body: Pick } + | { ok: false; errors: Record }; + +/** Порог отклонения, с которого процент и сумма краснеют (в сотых процента). */ +const RED_PERCENT_HUNDREDTHS = 500n; + +/** Без корректировки — по одной статье на сторону (первый код составной статьи) с суммой стороны; есть корректировка — её статьи. */ +export function initialEditorState(a: CorrectionAggregateOut): EditorState { + const c = a.correction; + if (c) { + return { + debit: c.debit.map((l) => ({ line_code: l.line_code, amount: l.amount })), + credit: c.credit.map((l) => ({ line_code: l.line_code, amount: l.amount })), + comment: c.comment, + }; + } + return { + debit: [{ line_code: articleCodes(a.side_1.article_line)[0] ?? '', amount: a.side_1.amount }], + credit: [{ line_code: articleCodes(a.side_2.article_line)[0] ?? '', amount: a.side_2.amount }], + comment: '', + }; +} + +/** Сумма строки с двумя знаками: «500000.5» → «500000.50». */ +function withCents(value: string): string { + const [int, frac = ''] = value.split('.'); + return `${int}.${frac.padEnd(2, '0')}`; +} + +function lineError(line: EditLine, prefix: string, errors: Record): CorrectionLineIn | null { + const code = line.line_code.trim(); + const raw = line.amount.trim(); + const parsed = raw === '' || /\s/.test(raw) ? null : parseAmountInput(raw); + let ok = true; + if (!/^\d{4}$/.test(code)) { + errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры'; + ok = false; + } + if (raw === '') { + errors[`${prefix}.amount`] = 'Введите сумму'; + ok = false; + } else if (/\s/.test(raw)) { + errors[`${prefix}.amount`] = 'Сумма без пробелов'; + ok = false; + } else if (!parsed?.ok || parsed.value === null) { + errors[`${prefix}.amount`] = 'Сумма: не более двух знаков после запятой'; + ok = false; + } else if (parsed.value.startsWith('-')) { + errors[`${prefix}.amount`] = 'Сумма не может быть отрицательной'; + ok = false; + } + if (!ok || !parsed?.ok || parsed.value === null) return null; + return { line_code: code, amount: withCents(parsed.value) }; +} + +/** Проверка статей перед сохранением: код — 4 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */ +export function validateLines(s: EditorState): LinesValidation { + const errors: Record = {}; + if (s.debit.length === 0 && s.credit.length === 0) { + errors.lines = 'Добавьте статью'; + } + const debit: CorrectionLineIn[] = []; + const credit: CorrectionLineIn[] = []; + s.debit.forEach((line, i) => { + const ok = lineError(line, `debit.${i}`, errors); + if (ok) debit.push(ok); + }); + s.credit.forEach((line, i) => { + const ok = lineError(line, `credit.${i}`, errors); + if (ok) credit.push(ok); + }); + if (Object.keys(errors).length > 0) return { ok: false, errors }; + return { ok: true, body: { debit, credit, comment: s.comment.trim() } }; +} + +/** Сумма статей стороны; некорректные строки в живом контроле пропускаются (их покажет validateLines). */ +function sideSum(lines: EditLine[]): string | null { + return sumAmounts( + lines.map((l) => { + const parsed = parseAmountInput(l.amount); + return parsed.ok && parsed.value !== null ? withCents(parsed.value) : null; + }), + ); +} + +/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ +function diffOf(left: string | null, right: string | null): Diff { + const l = left ?? '0.00'; + const r = right ?? '0.00'; + const lc = toCents(l); + const rc = toCents(r); + const difference = fromCents(lc > rc ? lc - rc : rc - lc); + const percent: Percent = percentOfSmaller(l, r); + const oneZero = (lc === 0n) !== (rc === 0n); + const red = (percent !== null && toCents(percent) >= RED_PERCENT_HUNDREDTHS) || oneZero; + return { left, right, difference, percent, red }; +} + +/** Живой контроль окна: исходные суммы сторон агрегата и распределение статей из текущего состояния редактора. */ +export function liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } { + return { + sourceDiff: diffOf(a.side_1.amount, a.side_2.amount), + allocationDiff: diffOf(sideSum(s.debit), sideSum(s.credit)), + }; +} + +function sameLines(x: EditLine[], y: EditLine[]): boolean { + return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && l.amount === y[i].amount); +} + +/** Есть ли несохранённые правки относительно исходного состояния окна. */ +export function isDirty(initial: EditorState, current: EditorState): boolean { + return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment; +} + +const opposite = (s: Side): Side => (s === 'D' ? 'K' : 'D'); + +/** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */ +export function pickerCandidates(table: CorrectionTableOut, a: CorrectionAggregateOut, side: 1 | 2): UnallocatedEntryOut[] { + const rule = table.rules.find((r) => r.id === a.rule_id); + if (!rule) return []; + const owner = side === 1 ? a.company_1.id : a.company_2.id; + const counterparty = side === 1 ? a.company_2.id : a.company_1.id; + const entrySide: Side = side === 1 ? rule.side_1 : opposite(rule.side_1); + return table.unallocated.filter( + (u) => + !u.excluded && + u.owner.id === owner && + u.counterparty.id === counterparty && + u.side === entrySide && + u.candidate_rule_ids.includes(a.rule_id), + ); +} + +/** Тело PUT: статус APPLIED и expected_version_no из ответа таблицы (null — действующей корректировки нет). */ +export function toCorrectionIn(body: Pick, expectedVersionNo: number | null): CorrectionIn { + return { ...body, status: 'APPLIED', expected_version_no: expectedVersionNo }; +}