diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts index e61d7bd..f22d7cf 100644 --- a/web/src/services/api/correctionsFake.test.ts +++ b/web/src/services/api/correctionsFake.test.ts @@ -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, 'ООО "БСК"')); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts index 2c3a8f0..76faf92 100644 --- a/web/src/services/api/correctionsFake.ts +++ b/web/src/services/api/correctionsFake.ts @@ -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 { 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' }); diff --git a/web/src/widgets/CorrectionTable/mockCalc.test.ts b/web/src/widgets/CorrectionTable/mockCalc.test.ts new file mode 100644 index 0000000..96cb78d --- /dev/null +++ b/web/src/widgets/CorrectionTable/mockCalc.test.ts @@ -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(); + }); +}); diff --git a/web/src/widgets/CorrectionTable/mockCalc.ts b/web/src/widgets/CorrectionTable/mockCalc.ts index 2541f14..5de9b80 100644 --- a/web/src/widgets/CorrectionTable/mockCalc.ts +++ b/web/src/widgets/CorrectionTable/mockCalc.ts @@ -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}`);