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:
parent
c05830face
commit
f4edb66f82
@ -74,13 +74,61 @@ describe('correctionsFake', () => {
|
|||||||
const u = t0.unallocated[0];
|
const u = t0.unallocated[0];
|
||||||
const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: 'r7' });
|
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);
|
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');
|
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' });
|
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.aggregates.find((a) => a.rule_id === 'r7')).toBeUndefined();
|
||||||
expect(t2.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.reason).toBe('UNASSIGNED');
|
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 () => {
|
it('saveCorrection: комментарий обязателен, версия проверяется', async () => {
|
||||||
const t = await fake.getCorrectionTable(V);
|
const t = await fake.getCorrectionTable(V);
|
||||||
const key = toAggregateKey(find(t, 'ООО "БСК"'));
|
const key = toAggregateKey(find(t, 'ООО "БСК"'));
|
||||||
|
|||||||
@ -58,7 +58,7 @@ function locate(id: string): Located {
|
|||||||
}
|
}
|
||||||
const item = state.unallocated.find((u) => u.entry_ids.includes(id));
|
const item = state.unallocated.find((u) => u.entry_ids.includes(id));
|
||||||
if (item) return { where: 'unallocated', item };
|
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 {
|
function refOf(a: CorrectionAggregateOut, id: string): Ref {
|
||||||
@ -81,7 +81,8 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code:
|
|||||||
side: e.side,
|
side: e.side,
|
||||||
amount: e.amount,
|
amount: e.amount,
|
||||||
entry_ids: e.entry_ids,
|
entry_ids: e.entry_ids,
|
||||||
candidates: e.candidate_rule_ids,
|
// прежнее правило становится одним из кандидатов, как при UNASSIGN
|
||||||
|
candidates: [...new Set([...e.candidate_rule_ids, a.rule_id])],
|
||||||
excluded: e.excluded,
|
excluded: e.excluded,
|
||||||
};
|
};
|
||||||
touch(a);
|
touch(a);
|
||||||
@ -90,21 +91,23 @@ function detach(found: Located): { owner: Ref; counterparty: Ref; account_code:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assign(found: Located, ruleId: string): void {
|
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;
|
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);
|
const moved = detach(found);
|
||||||
let target = state.aggregates.find(
|
// Сторона 1 правила — дебетовая: строка с этой стороной принадлежит компании 1, кредитовая — компании 2.
|
||||||
(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),
|
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) {
|
if (!target) {
|
||||||
target = {
|
target = {
|
||||||
rule_id: ruleId,
|
rule_id: ruleId,
|
||||||
rule_no: rule?.rule_no ?? 0,
|
rule_no: rule.rule_no,
|
||||||
company_1: moved.owner,
|
company_1: c1,
|
||||||
company_2: moved.counterparty,
|
company_2: c2,
|
||||||
side_1: { article_line: rule?.article_1_line ?? '', accounts: [], amount: '0.00', entries: [] },
|
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: [] },
|
side_2: { article_line: rule.article_2_line, accounts: [], amount: '0.00', entries: [] },
|
||||||
deviation_amount: '0.00',
|
deviation_amount: '0.00',
|
||||||
deviation_percent: null,
|
deviation_percent: null,
|
||||||
exceeds_threshold: false,
|
exceeds_threshold: false,
|
||||||
@ -126,7 +129,7 @@ function assign(found: Located, ruleId: string): void {
|
|||||||
excluded: moved.excluded,
|
excluded: moved.excluded,
|
||||||
candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId),
|
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);
|
touch(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +150,7 @@ function applyAction(id: string, body: AllocationIn): void {
|
|||||||
flag(false);
|
flag(false);
|
||||||
break;
|
break;
|
||||||
case 'ASSIGN':
|
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);
|
assign(found, body.rule_id as string);
|
||||||
break;
|
break;
|
||||||
case 'UNASSIGN': {
|
case 'UNASSIGN': {
|
||||||
@ -160,7 +163,7 @@ function applyAction(id: string, body: AllocationIn): void {
|
|||||||
account_code: moved.account_code,
|
account_code: moved.account_code,
|
||||||
side: moved.side,
|
side: moved.side,
|
||||||
amount: moved.amount,
|
amount: moved.amount,
|
||||||
candidate_rule_ids: [...new Set([...moved.candidates, found.aggregate.rule_id])],
|
candidate_rule_ids: moved.candidates,
|
||||||
reason: 'UNASSIGNED',
|
reason: 'UNASSIGNED',
|
||||||
excluded: moved.excluded,
|
excluded: moved.excluded,
|
||||||
});
|
});
|
||||||
@ -219,11 +222,19 @@ export const correctionsFake = {
|
|||||||
allocate(_versionId: string, body: AllocationIn): Promise<CorrectionTableOut> {
|
allocate(_versionId: string, body: AllocationIn): Promise<CorrectionTableOut> {
|
||||||
return run(() => {
|
return run(() => {
|
||||||
if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки');
|
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();
|
return snapshot();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// CSV-заглушка вместо xlsx сервера: проверяет только скачивание.
|
||||||
exportCorrectionTable(_versionId: string): Promise<{ blob: Blob; filename: string | null }> {
|
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(';'));
|
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' });
|
return Promise.resolve({ blob: new Blob([`${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), filename: 'corrections-mock.csv' });
|
||||||
|
|||||||
12
web/src/widgets/CorrectionTable/mockCalc.test.ts
Normal file
12
web/src/widgets/CorrectionTable/mockCalc.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections';
|
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections';
|
||||||
|
import { sumAmounts } from '../../utils/money';
|
||||||
// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки.
|
// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки.
|
||||||
// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ.
|
// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ.
|
||||||
import { sumAmounts } from '../../utils/money';
|
|
||||||
|
|
||||||
export const THRESHOLD_PERCENT_HUNDREDTHS = 500n;
|
export const THRESHOLD_PERCENT_HUNDREDTHS = 500n;
|
||||||
|
|
||||||
|
// toCents/fromCents дублируют BigInt-разбор из utils/money.ts: там он не экспортируется, а правка утилиты вне объёма задачи.
|
||||||
export function toCents(value: Money): bigint {
|
export function toCents(value: Money): bigint {
|
||||||
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value);
|
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value);
|
||||||
if (!match) throw new Error(`Не сумма: ${value}`);
|
if (!match) throw new Error(`Не сумма: ${value}`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user