fix(web): фейк корректировок по ревью Task 2 (#206)

Стороны нового агрегата по знаку строки, RULE_REQUIRED, ENTRY_NOT_IN_VERSION,
атомарность allocate, возврат прежнего правила в кандидаты, тесты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:05:13 +04:00
parent c05830face
commit f4edb66f82
4 changed files with 90 additions and 18 deletions

View File

@ -74,13 +74,61 @@ describe('correctionsFake', () => {
const u = t0.unallocated[0];
const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: 'r7' });
expect(t1.unallocated).toHaveLength(t0.unallocated.length - 1);
// строка кредитовая (K): владелец — компания 2, контрагент — компания 1, строка в side_2
expect(u.side).toBe('K');
const created = t1.aggregates.find((a) => a.rule_id === 'r7');
expect(created?.side_1.amount).toBe(u.amount);
expect(created?.company_2.name).toBe('ООО "БСК"');
expect(created?.company_1.name).toBe('ПМП ООО');
expect(created?.side_2.amount).toBe(u.amount);
expect(created?.side_1.entries).toEqual([]);
const t2 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'UNASSIGN' });
expect(t2.aggregates.find((a) => a.rule_id === 'r7')).toBeUndefined();
expect(t2.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.reason).toBe('UNASSIGNED');
});
it('ASSIGN без rule_id и неизвестная строка', async () => {
const u = (await fake.getCorrectionTable(V)).unallocated[0];
await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN' }), 422, 'RULE_REQUIRED');
await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION');
});
it('allocate атомарен: ошибка на второй строке откатывает первую', async () => {
const t = await fake.getCorrectionTable(V);
const first = t.unallocated[0].entry_ids[0];
await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION');
expect((await fake.getCorrectionTable(V)).unallocated[0].excluded).toBe(false);
});
it('перенос между правилами возвращает прежнее правило в кандидаты', async () => {
const t = await fake.getCorrectionTable(V);
const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03');
if (!row) throw new Error('нет строки');
const moved = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r7' });
const target = moved.aggregates.find((a) => a.rule_id === 'r7');
const e = target?.side_1.entries[0];
expect(e?.decision).toBe('ASSIGN');
expect(e?.candidate_rule_ids).toContain('r6');
expect(e?.candidate_rule_ids).not.toContain('r7');
});
it('INCLUDE возвращает строку, RESET сбрасывает решение', async () => {
const t = await fake.getCorrectionTable(V);
const row = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0];
await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' });
const back = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'INCLUDE' });
expect(find(back, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1675000.00');
await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'EXCLUDE' });
const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'RESET' });
expect(find(reset, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0].excluded).toBe(false);
});
it('агрегат с одной стороной: ONE_SIDED, порог превышен', async () => {
const a = find(await fake.getCorrectionTable(V), 'Агро-Холдинг');
expect(a.status).toBe('ONE_SIDED');
expect(a.exceeds_threshold).toBe(true);
expect(a.deviation_percent).toBeNull();
});
it('saveCorrection: комментарий обязателен, версия проверяется', async () => {
const t = await fake.getCorrectionTable(V);
const key = toAggregateKey(find(t, 'ООО "БСК"'));

View File

@ -58,7 +58,7 @@ function locate(id: string): Located {
}
const item = state.unallocated.find((u) => u.entry_ids.includes(id));
if (item) return { where: 'unallocated', item };
return fail(404, 'NOT_FOUND', 'Строка не найдена');
return fail(422, 'ENTRY_NOT_IN_VERSION', 'Строки нет среди строк версии');
}
function refOf(a: CorrectionAggregateOut, id: string): Ref {
@ -81,7 +81,8 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code:
side: e.side,
amount: e.amount,
entry_ids: e.entry_ids,
candidates: e.candidate_rule_ids,
// прежнее правило становится одним из кандидатов, как при UNASSIGN
candidates: [...new Set([...e.candidate_rule_ids, a.rule_id])],
excluded: e.excluded,
};
touch(a);
@ -90,21 +91,23 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code:
}
function assign(found: Located, ruleId: string): void {
const rule = state.rules.find((r) => r.id === ruleId);
const rule = state.rules.find((r) => r.id === ruleId) ?? fail(404, 'NOT_FOUND', 'Правило не найдено');
const candidates = found.where === 'unallocated' ? found.item.candidate_rule_ids : found.entry.candidate_rule_ids;
if (!rule || !candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило');
if (!candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило');
const moved = detach(found);
let target = state.aggregates.find(
(a) => a.rule_id === ruleId && [a.company_1.id, a.company_2.id].includes(moved.owner.id) && [a.company_1.id, a.company_2.id].includes(moved.counterparty.id),
);
// Сторона 1 правила — дебетовая: строка с этой стороной принадлежит компании 1, кредитовая — компании 2.
const isSide1 = moved.side === rule.side_1;
const c1 = isSide1 ? moved.owner : moved.counterparty;
const c2 = isSide1 ? moved.counterparty : moved.owner;
let target = state.aggregates.find((a) => a.rule_id === ruleId && a.company_1.id === c1.id && a.company_2.id === c2.id);
if (!target) {
target = {
rule_id: ruleId,
rule_no: rule?.rule_no ?? 0,
company_1: moved.owner,
company_2: moved.counterparty,
side_1: { article_line: rule?.article_1_line ?? '', accounts: [], amount: '0.00', entries: [] },
side_2: { article_line: rule?.article_2_line ?? '', accounts: [], amount: '0.00', entries: [] },
rule_no: rule.rule_no,
company_1: c1,
company_2: c2,
side_1: { article_line: rule.article_1_line, accounts: [], amount: '0.00', entries: [] },
side_2: { article_line: rule.article_2_line, accounts: [], amount: '0.00', entries: [] },
deviation_amount: '0.00',
deviation_percent: null,
exceeds_threshold: false,
@ -126,7 +129,7 @@ function assign(found: Located, ruleId: string): void {
excluded: moved.excluded,
candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId),
};
(target.company_1.id === moved.owner.id ? target.side_1 : target.side_2).entries.push(entry);
(isSide1 ? target.side_1 : target.side_2).entries.push(entry);
touch(target);
}
@ -147,7 +150,7 @@ function applyAction(id: string, body: AllocationIn): void {
flag(false);
break;
case 'ASSIGN':
if (!body.rule_id) fail(422, 'RULE_NOT_APPLICABLE', 'Не указано правило');
if (!body.rule_id) fail(422, 'RULE_REQUIRED', 'Для ASSIGN нужно правило');
assign(found, body.rule_id as string);
break;
case 'UNASSIGN': {
@ -160,7 +163,7 @@ function applyAction(id: string, body: AllocationIn): void {
account_code: moved.account_code,
side: moved.side,
amount: moved.amount,
candidate_rule_ids: [...new Set([...moved.candidates, found.aggregate.rule_id])],
candidate_rule_ids: moved.candidates,
reason: 'UNASSIGNED',
excluded: moved.excluded,
});
@ -219,11 +222,19 @@ export const correctionsFake = {
allocate(_versionId: string, body: AllocationIn): Promise<CorrectionTableOut> {
return run(() => {
if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки');
for (const id of body.entry_ids) applyAction(id, body);
// всё или ничего: при ошибке на любой строке возвращаем состояние, каким оно было
const backup = clone(state);
try {
for (const id of body.entry_ids) applyAction(id, body);
} catch (e) {
state = backup;
throw e;
}
return snapshot();
});
},
// CSV-заглушка вместо xlsx сервера: проверяет только скачивание.
exportCorrectionTable(_versionId: string): Promise<{ blob: Blob; filename: string | null }> {
const rows = state.aggregates.map((a) => [a.company_1.name, a.company_2.name, a.side_1.amount, a.side_2.amount, a.deviation_amount].join(';'));
return Promise.resolve({ blob: new Blob([`${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), filename: 'corrections-mock.csv' });

View File

@ -0,0 +1,12 @@
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();
});
});

View File

@ -1,10 +1,11 @@
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections';
import { sumAmounts } from '../../utils/money';
// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки.
// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ.
import { sumAmounts } from '../../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}`);