fix(web): правки по ревью Task 3 — утилиты денег, разбор сумм, черновик (#206)
- Копейки, процент и порог 5 % вынесены из mockCalc в utils/money; editorModel больше не зависит от фейка, тесты перенесены. - Один разбор суммы строки ввода для проверки, живого контроля и сравнения; некорректные строки в живой контроль не входят. - isDirty сравнивает суммы копейками; комментарий — точно. - Черновик: savedAt проверяется через Date.parse. - Тесты границ порога, пустых сторон и неизвестного rule_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8a7782eae
commit
ec6ece127f
@ -183,5 +183,5 @@
|
|||||||
| 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения |
|
| 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-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-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 не указаны | правка строк |
|
| 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк |
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 = ' ';
|
const NBSP = ' ';
|
||||||
|
|
||||||
@ -115,3 +115,28 @@ describe('formatScaledAmount', () => {
|
|||||||
expect(formatScaledAmount('1e5', rub(2))).toBe('1e5');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -114,3 +114,30 @@ export function sumAmounts(values: readonly (string | null | undefined)[]): stri
|
|||||||
const abs = negative ? -cents : cents;
|
const abs = negative ? -cents : cents;
|
||||||
return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@ -47,6 +47,14 @@ describe('saveDraft / loadDraft / clearDraft', () => {
|
|||||||
expect(loadDraft('k', s)).toBeNull();
|
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', () => {
|
it('испорченный JSON — null', () => {
|
||||||
const s = mapStorage();
|
const s = mapStorage();
|
||||||
s.setItem('k', '{не json');
|
s.setItem('k', '{не json');
|
||||||
|
|||||||
@ -29,7 +29,7 @@ function isLine(value: unknown): value is { line_code: string; amount: string }
|
|||||||
function isDraft(value: unknown): value is Draft {
|
function isDraft(value: unknown): value is Draft {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
const v = value as Record<string, unknown>;
|
const v = value as Record<string, unknown>;
|
||||||
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;
|
if (v.baseVersionNo !== null && typeof v.baseVersionNo !== 'number') return false;
|
||||||
const s = v.state as Record<string, unknown> | null;
|
const s = v.state as Record<string, unknown> | null;
|
||||||
if (typeof s !== 'object' || s === null) return false;
|
if (typeof s !== 'object' || s === null) return false;
|
||||||
|
|||||||
@ -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', () => {
|
describe('isDirty', () => {
|
||||||
it('одинаковые состояния — не грязно; правка комментария — грязно', () => {
|
it('одинаковые состояния — не грязно; правка комментария — грязно', () => {
|
||||||
const a = findBy(table(), 'Пензенский Мир Продуктов');
|
const a = findBy(table(), 'Пензенский Мир Продуктов');
|
||||||
@ -117,6 +160,15 @@ describe('isDirty', () => {
|
|||||||
expect(isDirty(init, initialEditorState(a))).toBe(false);
|
expect(isDirty(init, initialEditorState(a))).toBe(false);
|
||||||
expect(isDirty(init, { ...init, comment: 'x' })).toBe(true);
|
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', () => {
|
describe('toCorrectionIn', () => {
|
||||||
@ -167,6 +219,12 @@ describe('pickerCandidates', () => {
|
|||||||
expect(pickerCandidates(t, a, 1).map((u) => u.entry_ids[0])).toEqual(['d-ok']);
|
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('ключ агрегата не меняется от состояния редактора', () => {
|
it('ключ агрегата не меняется от состояния редактора', () => {
|
||||||
const a = findBy(table(), 'ЭПТ');
|
const a = findBy(table(), 'ЭПТ');
|
||||||
expect(aggregateKey(a)).toBe('r6|c-ept|c-ils');
|
expect(aggregateKey(a)).toBe('r6|c-ept|c-ils');
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ».
|
// Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ».
|
||||||
// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, процент и BigInt-копейки — из mockCalc.
|
// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, копейки и процент — из utils/money.
|
||||||
import type {
|
import type {
|
||||||
CorrectionAggregateOut,
|
CorrectionAggregateOut,
|
||||||
CorrectionIn,
|
CorrectionIn,
|
||||||
@ -9,8 +9,7 @@ import type {
|
|||||||
Side,
|
Side,
|
||||||
UnallocatedEntryOut,
|
UnallocatedEntryOut,
|
||||||
} from '../../types/corrections';
|
} from '../../types/corrections';
|
||||||
import { parseAmountInput, sumAmounts } from '../../utils/money';
|
import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from '../../utils/money';
|
||||||
import { fromCents, percentOfSmaller, toCents } from './mockCalc';
|
|
||||||
import { articleCodes } from './tableModel';
|
import { articleCodes } from './tableModel';
|
||||||
|
|
||||||
export type EditLine = { line_code: string; amount: string };
|
export type EditLine = { line_code: string; amount: string };
|
||||||
@ -22,9 +21,6 @@ export type LinesValidation =
|
|||||||
| { ok: true; body: Pick<CorrectionIn, 'debit' | 'credit' | 'comment'> }
|
| { ok: true; body: Pick<CorrectionIn, 'debit' | 'credit' | 'comment'> }
|
||||||
| { ok: false; errors: Record<string, string> };
|
| { ok: false; errors: Record<string, string> };
|
||||||
|
|
||||||
/** Порог отклонения, с которого процент и сумма краснеют (в сотых процента). */
|
|
||||||
const RED_PERCENT_HUNDREDTHS = 500n;
|
|
||||||
|
|
||||||
/** Без корректировки — по одной статье на сторону (первый код составной статьи) с суммой стороны; есть корректировка — её статьи. */
|
/** Без корректировки — по одной статье на сторону (первый код составной статьи) с суммой стороны; есть корректировка — её статьи. */
|
||||||
export function initialEditorState(a: CorrectionAggregateOut): EditorState {
|
export function initialEditorState(a: CorrectionAggregateOut): EditorState {
|
||||||
const c = a.correction;
|
const c = a.correction;
|
||||||
@ -48,30 +44,34 @@ function withCents(value: string): string {
|
|||||||
return `${int}.${frac.padEnd(2, '0')}`;
|
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<string, string>): CorrectionLineIn | null {
|
function lineError(line: EditLine, prefix: string, errors: Record<string, string>): CorrectionLineIn | null {
|
||||||
const code = line.line_code.trim();
|
const code = line.line_code.trim();
|
||||||
const raw = line.amount.trim();
|
const codeOk = /^\d{4}$/.test(code);
|
||||||
const parsed = raw === '' || /\s/.test(raw) ? null : parseAmountInput(raw);
|
const amount = parseLineAmount(line.amount);
|
||||||
let ok = true;
|
if (!codeOk) errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры';
|
||||||
if (!/^\d{4}$/.test(code)) {
|
if (!amount.ok) errors[`${prefix}.amount`] = amount.error;
|
||||||
errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры';
|
if (!codeOk || !amount.ok) return null;
|
||||||
ok = false;
|
return { line_code: code, amount: amount.value };
|
||||||
}
|
|
||||||
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 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */
|
/** Проверка статей перед сохранением: код — 4 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */
|
||||||
@ -94,14 +94,12 @@ export function validateLines(s: EditorState): LinesValidation {
|
|||||||
return { ok: true, body: { debit, credit, comment: s.comment.trim() } };
|
return { ok: true, body: { debit, credit, comment: s.comment.trim() } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сумма статей стороны; некорректные строки в живом контроле пропускаются (их покажет validateLines). */
|
/** Сумма статей стороны для живого контроля: строки, не прошедшие parseLineAmount, в сумму не входят (их покажет validateLines). */
|
||||||
function sideSum(lines: EditLine[]): string | null {
|
function sideSum(lines: EditLine[]): string | null {
|
||||||
return sumAmounts(
|
return sumAmounts(lines.map((l) => {
|
||||||
lines.map((l) => {
|
const r = parseLineAmount(l.amount);
|
||||||
const parsed = parseAmountInput(l.amount);
|
return r.ok ? r.value : null;
|
||||||
return parsed.ok && parsed.value !== null ? withCents(parsed.value) : null;
|
}));
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */
|
/** Разница двух сумм и процент к меньшей; красный — процент ≥ 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 difference = fromCents(lc > rc ? lc - rc : rc - lc);
|
||||||
const percent: Percent = percentOfSmaller(l, r);
|
const percent: Percent = percentOfSmaller(l, r);
|
||||||
const oneZero = (lc === 0n) !== (rc === 0n);
|
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 };
|
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 {
|
export function isDirty(initial: EditorState, current: EditorState): boolean {
|
||||||
return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment;
|
return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,35 +1,10 @@
|
|||||||
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections';
|
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money } from '../../types/corrections';
|
||||||
import { sumAmounts } from '../../utils/money';
|
import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, percentOfSmaller, sumAmounts, toCents } from '../../utils/money';
|
||||||
// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки.
|
// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки.
|
||||||
// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ.
|
// Это фейк контракта (раздел 4): сервер считает то же сам. Экран эти функции не вызывает, только утилиты из utils/money.
|
||||||
|
|
||||||
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')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sumOrZero = (values: readonly Money[]): Money => sumAmounts(values) ?? '0.00';
|
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 {
|
function sideAmount(side: AggregateSideOut): void {
|
||||||
side.amount = sumOrZero(side.entries.filter((e) => !e.excluded).map((e) => e.amount));
|
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();
|
side.accounts = [...new Set(side.entries.map((e) => e.account_code))].sort();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user