diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 1a2afcf..36eb34a 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -183,5 +183,5 @@ | 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-5: `percentOfSmaller`, `toCents`, `fromCents` и порог 5 % (`THRESHOLD_PERCENT_HUNDREDTHS`) вынесены в `utils/money.ts`; `editorModel` и `mockCalc` импортируют оттуда, зависимости `editorModel` → `mockCalc` нет | фейк `mockCalc` вырезается при подключении API, экран не должен от него зависеть (ревью Task 3, T3.1) | поведение то же; тесты перенесены в `utils/money.test.ts` | | 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк | diff --git a/web/src/utils/money.test.ts b/web/src/utils/money.test.ts index 88a6ff0..561c875 100644 --- a/web/src/utils/money.test.ts +++ b/web/src/utils/money.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money'; +import { THRESHOLD_PERCENT_HUNDREDTHS, formatAmount, formatScaledAmount, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from './money'; const NBSP = ' '; @@ -115,3 +115,28 @@ describe('formatScaledAmount', () => { expect(formatScaledAmount('1e5', rub(2))).toBe('1e5'); }); }); + +describe('percentOfSmaller', () => { + it('округляет половину вверх', () => { + expect(percentOfSmaller('9.00', '8.00')).toBe('12.50'); + expect(percentOfSmaller('1.00', '3.00')).toBe('200.00'); + // отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх) + expect(percentOfSmaller('8.01', '8.00')).toBe('0.13'); + expect(percentOfSmaller('0.00', '5.00')).toBeNull(); + }); +}); + +describe('toCents / fromCents', () => { + it('копейки и обратно, без float', () => { + expect(toCents('-1.5')).toBe(-150n); + expect(toCents('9007199254740993.01')).toBe(900719925474099301n); + expect(fromCents(-5n)).toBe('-0.05'); + expect(fromCents(0n)).toBe('0.00'); + }); + it('не сумма — ошибка', () => { + expect(() => toCents('1e5')).toThrow(); + }); + it('порог — 5 % в сотых', () => { + expect(THRESHOLD_PERCENT_HUNDREDTHS).toBe(500n); + }); +}); diff --git a/web/src/utils/money.ts b/web/src/utils/money.ts index f1e6082..f9c3ae7 100644 --- a/web/src/utils/money.ts +++ b/web/src/utils/money.ts @@ -114,3 +114,30 @@ export function sumAmounts(values: readonly (string | null | undefined)[]): stri const abs = negative ? -cents : cents; return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; } + +/** Порог отклонения в сотых процента: 5 % = 500. Выше или равно — «красный» (см. контракт, `exceeds_threshold`). */ +export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; + +/** Строка суммы (`"-12.5"`, до двух знаков) → копейки BigInt. Не сумма — ошибка. */ +export function toCents(value: string): bigint { + const match = MONEY.exec(value); + if (!match) throw new Error(`Не сумма: ${value}`); + const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); + return match[1] ? -cents : cents; +} + +/** Копейки BigInt → строка суммы с двумя знаками (`"-0.05"`). */ +export function fromCents(cents: bigint): string { + const abs = cents < 0n ? -cents : cents; + return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; +} + +/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ +export function percentOfSmaller(a: string, b: string): string | null { + const x = toCents(a); + const y = toCents(b); + const min = x < y ? x : y; + if (min <= 0n) return null; + const diff = x < y ? y - x : x - y; + return fromCents((diff * 20000n + min) / (2n * min)); +} diff --git a/web/src/widgets/CorrectionTable/draftStorage.test.ts b/web/src/widgets/CorrectionTable/draftStorage.test.ts index 5f87b0f..e17edf1 100644 --- a/web/src/widgets/CorrectionTable/draftStorage.test.ts +++ b/web/src/widgets/CorrectionTable/draftStorage.test.ts @@ -47,6 +47,14 @@ describe('saveDraft / loadDraft / clearDraft', () => { expect(loadDraft('k', s)).toBeNull(); }); + it('мусорный savedAt — null', () => { + const s = mapStorage(); + s.setItem('k', JSON.stringify({ ...draft(null), savedAt: 'не дата' })); + expect(loadDraft('k', s)).toBeNull(); + s.setItem('k', JSON.stringify({ ...draft(null), savedAt: '' })); + expect(loadDraft('k', s)).toBeNull(); + }); + it('испорченный JSON — null', () => { const s = mapStorage(); s.setItem('k', '{не json'); diff --git a/web/src/widgets/CorrectionTable/draftStorage.ts b/web/src/widgets/CorrectionTable/draftStorage.ts index e290bf2..3591d26 100644 --- a/web/src/widgets/CorrectionTable/draftStorage.ts +++ b/web/src/widgets/CorrectionTable/draftStorage.ts @@ -29,7 +29,7 @@ function isLine(value: unknown): value is { line_code: string; 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 (typeof v.savedAt !== 'string' || !Number.isFinite(Date.parse(v.savedAt))) 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; diff --git a/web/src/widgets/CorrectionTable/editorModel.test.ts b/web/src/widgets/CorrectionTable/editorModel.test.ts index 7931833..6bae288 100644 --- a/web/src/widgets/CorrectionTable/editorModel.test.ts +++ b/web/src/widgets/CorrectionTable/editorModel.test.ts @@ -110,6 +110,49 @@ describe('liveControl', () => { }); }); +describe('граница порога и пустые стороны', () => { + const base = { debit: [], credit: [], comment: '' }; + it('отклонение ровно 5.00 % — красный', () => { + const a = findBy(table(), 'ЭПТ'); + // 100.00 к 105.00 — 5.00 % к меньшей + const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '105.00' }] }); + expect(c.allocationDiff.percent).toBe('5.00'); + expect(c.allocationDiff.red).toBe(true); + }); + it('отклонение 4.99 % — не красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '104.99' }] }); + expect(c.allocationDiff.percent).toBe('4.99'); + expect(c.allocationDiff.red).toBe(false); + }); + it('обе стороны распределения пусты — не красный, процент null', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, base); + expect(c.allocationDiff).toEqual({ left: null, right: null, difference: '0.00', percent: null, red: false }); + }); + it('обе стороны с нулевой суммой — не красный', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '0' }], credit: [{ line_code: '1520', amount: '0.00' }] }); + expect(c.allocationDiff.red).toBe(false); + }); +}); + +describe('живой контроль не считает некорректные строки', () => { + it('строки с пробелами и отрицательные не входят в сумму стороны', () => { + const a = findBy(table(), 'ЭПТ'); + const c = liveControl(a, { + debit: [ + { line_code: '1230', amount: '1 000' }, + { line_code: '1230', amount: '-5' }, + { line_code: '1230', amount: '5,5' }, + ], + credit: [], + comment: '', + }); + expect(c.allocationDiff.left).toBe('5.50'); + }); +}); + describe('isDirty', () => { it('одинаковые состояния — не грязно; правка комментария — грязно', () => { const a = findBy(table(), 'Пензенский Мир Продуктов'); @@ -117,6 +160,15 @@ describe('isDirty', () => { expect(isDirty(init, initialEditorState(a))).toBe(false); expect(isDirty(init, { ...init, comment: 'x' })).toBe(true); }); + it('сумма сравнивается копейками: 1000000.00 и 1000000,00 равны; другая сумма — нет', () => { + const base: EditorState = { debit: [{ line_code: '1230', amount: '1000000.00' }], credit: [], comment: '' }; + expect(isDirty(base, { ...base, debit: [{ line_code: '1230', amount: '1000000,00' }] })).toBe(false); + expect(isDirty(base, { ...base, debit: [{ line_code: '1230', amount: '1000000,01' }] })).toBe(true); + }); + it('комментарий сравнивается точно: пробел в конце — правка', () => { + const base: EditorState = { debit: [], credit: [], comment: 'сверка' }; + expect(isDirty(base, { ...base, comment: 'сверка ' })).toBe(true); + }); }); describe('toCorrectionIn', () => { @@ -167,6 +219,12 @@ describe('pickerCandidates', () => { expect(pickerCandidates(t, a, 1).map((u) => u.entry_ids[0])).toEqual(['d-ok']); }); + it('неизвестный rule_id — пустой список', () => { + const t = table(); + const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), rule_id: 'no-such-rule' }; + expect(pickerCandidates(t, a, 2)).toEqual([]); + }); + 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 index 84e4676..43540e5 100644 --- a/web/src/widgets/CorrectionTable/editorModel.ts +++ b/web/src/widgets/CorrectionTable/editorModel.ts @@ -1,5 +1,5 @@ // Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ». -// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, процент и BigInt-копейки — из mockCalc. +// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, копейки и процент — из utils/money. import type { CorrectionAggregateOut, CorrectionIn, @@ -9,8 +9,7 @@ import type { Side, UnallocatedEntryOut, } from '../../types/corrections'; -import { parseAmountInput, sumAmounts } from '../../utils/money'; -import { fromCents, percentOfSmaller, toCents } from './mockCalc'; +import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from '../../utils/money'; import { articleCodes } from './tableModel'; export type EditLine = { line_code: string; amount: string }; @@ -22,9 +21,6 @@ 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; @@ -48,30 +44,34 @@ function withCents(value: string): string { return `${int}.${frac.padEnd(2, '0')}`; } +const AMOUNT_ERROR = { + empty: 'Введите сумму', + spaces: 'Сумма без пробелов', + format: 'Сумма: не более двух знаков после запятой', + negative: 'Сумма не может быть отрицательной', +} as const; + +type AmountResult = { ok: true; value: string } | { ok: false; error: string }; + +/** Разбор суммы строки ввода — общий для проверки, живого контроля и сравнения. Строка с двумя знаками или причина отказа. */ +function parseLineAmount(raw: string): AmountResult { + const text = raw.trim(); + if (text === '') return { ok: false, error: AMOUNT_ERROR.empty }; + if (/\s/.test(text)) return { ok: false, error: AMOUNT_ERROR.spaces }; + const parsed = parseAmountInput(text); + if (!parsed.ok || parsed.value === null) return { ok: false, error: AMOUNT_ERROR.format }; + if (parsed.value.startsWith('-')) return { ok: false, error: AMOUNT_ERROR.negative }; + return { ok: true, value: withCents(parsed.value) }; +} + 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) }; + const codeOk = /^\d{4}$/.test(code); + const amount = parseLineAmount(line.amount); + if (!codeOk) errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры'; + if (!amount.ok) errors[`${prefix}.amount`] = amount.error; + if (!codeOk || !amount.ok) return null; + return { line_code: code, amount: amount.value }; } /** Проверка статей перед сохранением: код — 4 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */ @@ -94,14 +94,12 @@ export function validateLines(s: EditorState): LinesValidation { return { ok: true, body: { debit, credit, comment: s.comment.trim() } }; } -/** Сумма статей стороны; некорректные строки в живом контроле пропускаются (их покажет validateLines). */ +/** Сумма статей стороны для живого контроля: строки, не прошедшие parseLineAmount, в сумму не входят (их покажет 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; - }), - ); + return sumAmounts(lines.map((l) => { + const r = parseLineAmount(l.amount); + return r.ok ? r.value : null; + })); } /** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ @@ -113,7 +111,7 @@ function diffOf(left: string | null, right: string | null): Diff { 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; + const red = (percent !== null && toCents(percent) >= THRESHOLD_PERCENT_HUNDREDTHS) || oneZero; return { left, right, difference, percent, red }; } @@ -125,11 +123,17 @@ export function liveControl(a: CorrectionAggregateOut, s: EditorState): { source }; } -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); +/** Сумма для сравнения черновика с исходным состоянием: копейки, если разбирается; иначе сама строка. */ +function amountKey(raw: string): string { + const r = parseLineAmount(raw); + return r.ok ? toCents(r.value).toString() : `raw:${raw}`; } -/** Есть ли несохранённые правки относительно исходного состояния окна. */ +function sameLines(x: EditLine[], y: EditLine[]): boolean { + return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && amountKey(l.amount) === amountKey(y[i].amount)); +} + +/** Есть ли несохранённые правки. Суммы сравниваются копейками (`1000000.00` = `1000000,00`); комментарий — точно, как введён. */ export function isDirty(initial: EditorState, current: EditorState): boolean { return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment; } diff --git a/web/src/widgets/CorrectionTable/mockCalc.test.ts b/web/src/widgets/CorrectionTable/mockCalc.test.ts deleted file mode 100644 index 96cb78d..0000000 --- a/web/src/widgets/CorrectionTable/mockCalc.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { percentOfSmaller } from './mockCalc'; - -describe('percentOfSmaller', () => { - it('округляет половину вверх', () => { - expect(percentOfSmaller('9.00', '8.00')).toBe('12.50'); - expect(percentOfSmaller('1.00', '3.00')).toBe('200.00'); - // отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх) - expect(percentOfSmaller('8.01', '8.00')).toBe('0.13'); - expect(percentOfSmaller('0.00', '5.00')).toBeNull(); - }); -}); diff --git a/web/src/widgets/CorrectionTable/mockCalc.ts b/web/src/widgets/CorrectionTable/mockCalc.ts index 5de9b80..7221f3d 100644 --- a/web/src/widgets/CorrectionTable/mockCalc.ts +++ b/web/src/widgets/CorrectionTable/mockCalc.ts @@ -1,35 +1,10 @@ -import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections'; -import { sumAmounts } from '../../utils/money'; +import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money } from '../../types/corrections'; +import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, percentOfSmaller, sumAmounts, toCents } from '../../utils/money'; // Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки. -// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ. - -export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; - -// toCents/fromCents дублируют BigInt-разбор из utils/money.ts: там он не экспортируется, а правка утилиты вне объёма задачи. -export function toCents(value: Money): bigint { - const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value); - if (!match) throw new Error(`Не сумма: ${value}`); - const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); - return match[1] ? -cents : cents; -} - -export function fromCents(cents: bigint): Money { - const abs = cents < 0n ? -cents : cents; - return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; -} +// Это фейк контракта (раздел 4): сервер считает то же сам. Экран эти функции не вызывает, только утилиты из utils/money. const sumOrZero = (values: readonly Money[]): Money => sumAmounts(values) ?? '0.00'; -/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ -export function percentOfSmaller(a: Money, b: Money): Percent { - const x = toCents(a); - const y = toCents(b); - const min = x < y ? x : y; - if (min <= 0n) return null; - const diff = x < y ? y - x : x - y; - return fromCents((diff * 20000n + min) / (2n * min)); -} - function sideAmount(side: AggregateSideOut): void { side.amount = sumOrZero(side.entries.filter((e) => !e.excluded).map((e) => e.amount)); side.accounts = [...new Set(side.entries.map((e) => e.account_code))].sort();