AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215

Merged
aep merged 29 commits from feat/206-corrections-ui into main 2026-11-02 20:12:17 +03:00
5 changed files with 511 additions and 0 deletions
Showing only changes of commit b8a7782eae - Show all commits

View File

@ -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 не указаны | правка строк |

View File

@ -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<string, string>();
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);
});
});

View File

@ -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<Storage, 'getItem' | 'setItem' | 'removeItem'>;
/** Ключ: пользователь + версия + агрегат. */
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<string, unknown>;
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<string, unknown>;
if (typeof v.savedAt !== 'string') return false;
if (v.baseVersionNo !== null && typeof v.baseVersionNo !== 'number') return false;
const s = v.state as Record<string, unknown> | 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);
}

View File

@ -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> = {}): 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');
});
});

View File

@ -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<CorrectionIn, 'debit' | 'credit' | 'comment'> }
| { ok: false; errors: Record<string, string> };
/** Порог отклонения, с которого процент и сумма краснеют (в сотых процента). */
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<string, string>): 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<string, string> = {};
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<CorrectionIn, 'debit' | 'credit' | 'comment'>, expectedVersionNo: number | null): CorrectionIn {
return { ...body, status: 'APPLIED', expected_version_no: expectedVersionNo };
}