diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2aa72d4..f4d6c15 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -148,5 +148,5 @@ export const api = { post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), - del: (path: string) => apiFetch(path, { method: 'DELETE' }), + del: (path: string, body?: unknown) => apiFetch(path, withJsonBody('DELETE', body)), }; diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts deleted file mode 100644 index 5cdd50f..0000000 --- a/web/src/services/api/correctionsFake.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { ApiError } from '../../api/client'; -import type { CorrectionAggregateOut, CorrectionIn } from '../../types/corrections'; -import { MOCK_VERSION_ID, makeMockTable } from '../../widgets/CorrectionTable/mockData'; -import { aggregateKey, toAggregateKey } from '../../widgets/CorrectionTable/tableModel'; -import { correctionsFake as fake, resetCorrectionsFake } from './correctionsFake'; - -const V = MOCK_VERSION_ID; -const find = (t: { aggregates: CorrectionAggregateOut[] }, c1: string) => { - const a = t.aggregates.find((x) => x.company_1.name === c1); - if (!a) throw new Error('нет агрегата'); - return a; -}; -const body = (over: Partial = {}): CorrectionIn => ({ - debit: [{ line_code: '1230', amount: '10.00' }], - credit: [{ line_code: '1520', amount: '10.00' }], - comment: 'ок', - expected_version_no: null, - ...over, -}); -const expectCode = async (p: Promise, status: number, code: string) => { - const err = await p.then( - () => null, - (e) => e, - ); - expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(status); - expect(err.code).toBe(code); -}; - -beforeEach(() => resetCorrectionsFake()); - -describe('correctionsFake', () => { - it('таблица из дизайна: ЭПТ — ИЛС', async () => { - const t = await fake.getCorrectionTable(V); - const a = find(t, 'ЭПТ'); - expect(a.side_1.amount).toBe('1000000.00'); - expect(a.side_2.amount).toBe('202320.00'); - expect(a.deviation_percent).toBe('394.27'); - expect(a.exceeds_threshold).toBe(true); - expect(a.correction?.comment).toBe('Требуется сверка с контрагентом'); - expect(t.editable).toBe(true); - }); - - it('EXCLUDE / INCLUDE у строки агрегата — 422 ENTRY_ALLOCATED: строки агрегата учитываются всегда', async () => { - const a = find(await fake.getCorrectionTable(V), 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"'); - const row = a.side_1.entries[0]; - for (const action of ['EXCLUDE', 'INCLUDE'] as const) { - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action }), - 422, - 'ENTRY_ALLOCATED', - ); - } - }); - - it('UNASSIGN строки 62.03: сумма стороны меньше, строка — в нераспределённых со строкой баланса владельца', async () => { - const table0 = await fake.getCorrectionTable(V); - const row = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries.find((e) => e.account_code === '62.03'); - if (!row) throw new Error('нет строки'); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'UNASSIGN' }); - expect(find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.amount).toBe('1325000.00'); - expect(table0.totals.amount_1).toBe('37727550.00'); - expect(t.totals.amount_1).toBe('37377550.00'); - const u = t.unallocated.find((x) => x.entry_ids[0] === row.entry_ids[0]); - expect(u?.reason).toBe('UNASSIGNED'); - expect(u?.line_code).toBe('1230'); - expect(u?.excluded).toBe(false); - }); - - it('UNASSIGN в агрегате с корректировкой отмечает смену состава', async () => { - const t0 = await fake.getCorrectionTable(V); - const row = find(t0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0]; - const key = toAggregateKey(find(t0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"')); - await fake.saveCorrection(V, key, body()); - const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'UNASSIGN' }); - expect(find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').composition_changed).toBe(true); - }); - - it('ASSIGN: только в правило из candidate_rule_ids', 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('нет строки'); - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'ASSIGN', rule_id: 'r1' }), - 422, - 'RULE_NOT_APPLICABLE', - ); - }); - - it('ASSIGN нераспределённой строки в правило создаёт агрегат, UNASSIGN возвращает строку', async () => { - const t0 = await fake.getCorrectionTable(V); - const u = t0.unallocated[0]; - const t1 = await fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: u.side, 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?.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, side: u.side, 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, side: u.side, action: 'ASSIGN' }), - 422, - 'RULE_REQUIRED', - ); - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], side: 'D', action: 'EXCLUDE' }), - 422, - 'ENTRY_NOT_IN_VERSION', - ); - }); - - it('позиция ищется по строке и стороне: другая сторона той же строки — ENTRY_NOT_IN_VERSION', async () => { - const u = (await fake.getCorrectionTable(V)).unallocated[0]; - const other = u.side === 'D' ? 'K' : 'D'; - await expectCode( - fake.allocate(V, { statement_type: 'balance', entry_ids: u.entry_ids, side: other, 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, 'нет'], side: t.unallocated[0].side, 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, - side: row.side, - 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('нераспределённая строка: EXCLUDE / INCLUDE переключают отметку, исключённую в правило не отнести', async () => { - const u = (await fake.getCorrectionTable(V)).unallocated[0]; - const pos = { statement_type: 'balance' as const, entry_ids: u.entry_ids, side: u.side }; - const out = await fake.allocate(V, { ...pos, action: 'EXCLUDE' }); - expect(out.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.excluded).toBe(true); - await expectCode(fake.allocate(V, { ...pos, action: 'ASSIGN', rule_id: u.candidate_rule_ids[0] }), 422, 'ENTRY_EXCLUDED'); - const back = await fake.allocate(V, { ...pos, action: 'INCLUDE' }); - expect(back.unallocated.find((x) => x.entry_ids[0] === u.entry_ids[0])?.excluded).toBe(false); - }); - - it('RESET у строки агрегата сбрасывает ручное отнесение', 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, - side: row.side, - action: 'ASSIGN', - rule_id: 'r7', - }); - const e = moved.aggregates.find((a) => a.rule_id === 'r7')?.side_1.entries[0]; - expect(e?.decision).toBe('ASSIGN'); - const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'RESET' }); - expect(reset.aggregates.find((a) => a.rule_id === 'r7')?.side_1.entries[0].decision).toBe('AUTO'); - }); - - it('у нераспределённых есть строка баланса владельца', async () => { - const t = await fake.getCorrectionTable(V); - expect(t.unallocated.every((u) => u.line_code !== undefined)).toBe(true); - expect(t.unallocated.some((u) => u.line_code !== null)).toBe(true); - }); - - 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, 'ООО "БСК"')); - await expectCode(fake.saveCorrection(V, key, body({ comment: ' ' })), 422, 'COMMENT_REQUIRED'); - const saved = await fake.saveCorrection(V, key, body()); - expect(saved.correction?.version_no).toBe(1); - expect(saved.correction?.balance.debit_total).toBe('10.00'); - await expectCode(fake.saveCorrection(V, key, body()), 409, 'CORRECTION_VERSION_CONFLICT'); - const again = await fake.saveCorrection(V, key, body({ expected_version_no: 1 })); - expect(again.correction?.version_no).toBe(2); - }); - - it('сохранение всегда применяет корректировку (APPLIED)', async () => { - const key = toAggregateKey(find(await fake.getCorrectionTable(V), 'ЭПТ')); - const saved = await fake.saveCorrection(V, key, body({ expected_version_no: 1 })); - expect(saved.correction?.status).toBe('APPLIED'); - expect(aggregateKey(saved)).toBe(aggregateKey(find(await fake.getCorrectionTable(V), 'ЭПТ'))); - }); - - it('ответы — копии: правка снаружи не портит состояние', async () => { - const t = await fake.getCorrectionTable(V); - t.aggregates.length = 0; - expect((await fake.getCorrectionTable(V)).aggregates.length).toBe(makeMockTable().aggregates.length); - }); -}); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts deleted file mode 100644 index bd55a96..0000000 --- a/web/src/services/api/correctionsFake.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Фейк контракта корректировок (docs/dev/api-contract-corrections.md) для REACT_APP_CORRECTIONS_MOCK: состояние в памяти модуля. -// Повторяет проверки сервера (комментарий, редакция, применимость правила), чтобы экран отрабатывал те же ошибки. -import { ApiError } from '../../api/client'; -import type { - AggregateKey, - AllocationIn, - CorrectionAggregateOut, - CorrectionEntryOut, - CorrectionIn, - CorrectionTableOut, - Ref, - Side, - UnallocatedEntryOut, -} from '../../types/corrections'; -import { computeTotals, recomputeAggregate } from '../../widgets/CorrectionTable/mockCalc'; -import { makeMockTable } from '../../widgets/CorrectionTable/mockData'; -import { aggregateKey, articleCodes } from '../../widgets/CorrectionTable/tableModel'; - -let state: CorrectionTableOut = makeMockTable(); - -export function resetCorrectionsFake(): void { - state = makeMockTable(); -} - -const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -const fail = (status: number, code: string, message: string): never => { - throw new ApiError(status, code, message, { detail: { code, message } }); -}; - -const keyOf = (k: AggregateKey): string => `${k.rule_id}|${k.company_1_id}|${k.company_2_id}`; - -function snapshot(): CorrectionTableOut { - state.totals = computeTotals(state.aggregates); - return clone(state); -} - -function aggregateByKey(k: AggregateKey): CorrectionAggregateOut { - return state.aggregates.find((a) => aggregateKey(a) === keyOf(k)) ?? fail(404, 'NOT_FOUND', 'Агрегат не найден'); -} - -/** Состав агрегата с корректировкой изменился — сервер помечает `composition_changed`. */ -function touch(a: CorrectionAggregateOut): void { - recomputeAggregate(a); - if (a.correction) a.composition_changed = true; -} - -type Located = - | { where: 'aggregate'; aggregate: CorrectionAggregateOut; sideNo: 1 | 2; entry: CorrectionEntryOut } - | { where: 'unallocated'; item: UnallocatedEntryOut }; - -/** Позиция — строка свода и сторона: у строки с обоими сальдо две позиции, решение касается одной. */ -function locate(id: string, side: Side): Located { - for (const aggregate of state.aggregates) { - for (const sideNo of [1, 2] as const) { - const entry = (sideNo === 1 ? aggregate.side_1 : aggregate.side_2).entries.find((e) => e.side === side && e.entry_ids.includes(id)); - if (entry) return { where: 'aggregate', aggregate, sideNo, entry }; - } - } - const item = state.unallocated.find((u) => u.side === side && u.entry_ids.includes(id)); - if (item) return { where: 'unallocated', item }; - return fail(422, 'ENTRY_NOT_IN_VERSION', 'Позиции нет среди строк версии'); -} - -function refOf(a: CorrectionAggregateOut, id: string): Ref { - return a.company_1.id === id ? a.company_1 : a.company_2; -} - -function detach(found: Located): { - owner: Ref; - counterparty: Ref; - account_code: string; - line_code: string | null; - side: 'D' | 'K'; - amount: string; - entry_ids: string[]; - candidates: string[]; -} { - if (found.where === 'unallocated') { - const u = found.item; - state.unallocated = state.unallocated.filter((x) => x !== u); - return { - owner: u.owner, - counterparty: u.counterparty, - account_code: u.account_code, - line_code: u.line_code, - side: u.side, - amount: u.amount, - entry_ids: u.entry_ids, - candidates: u.candidate_rule_ids, - }; - } - const { aggregate: a, sideNo, entry: e } = found; - const side = sideNo === 1 ? a.side_1 : a.side_2; - side.entries = side.entries.filter((x) => x !== e); - const out = { - owner: refOf(a, e.legal_entity_id), - counterparty: refOf(a, e.counterparty_id), - account_code: e.account_code, - // строка баланса владельца — статья его стороны в правиле (у составной — первый код) - line_code: articleCodes(side.article_line)[0] ?? null, - side: e.side, - amount: e.amount, - entry_ids: e.entry_ids, - // прежнее правило становится одним из кандидатов, как при UNASSIGN - candidates: [...new Set([...e.candidate_rule_ids, a.rule_id])], - }; - touch(a); - if (!a.side_1.entries.length && !a.side_2.entries.length) state.aggregates = state.aggregates.filter((x) => x !== a); - return out; -} - -function assign(found: Located, ruleId: string): void { - 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; - // исключённая из КФО строка в правило молча не возвращается: сначала её снова учитывают (INCLUDE) - if (found.where === 'unallocated' && found.item.excluded) fail(422, 'ENTRY_EXCLUDED', 'Строка не учитывается в КФО'); - if (!candidates.includes(ruleId)) fail(422, 'RULE_NOT_APPLICABLE', 'Строка не подходит под это правило'); - const moved = detach(found); - // Сторона 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, - 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, - status: 'AUTO', - eliminated_amount: '0.00', - composition_changed: false, - correction: null, - }; - state.aggregates.push(target); - } - const entry: CorrectionEntryOut = { - entry_ids: moved.entry_ids, - legal_entity_id: moved.owner.id, - counterparty_id: moved.counterparty.id, - account_code: moved.account_code, - side: moved.side, - amount: moved.amount, - decision: 'ASSIGN', - candidate_rule_ids: moved.candidates.filter((id) => id !== ruleId), - }; - (isSide1 ? target.side_1 : target.side_2).entries.push(entry); - touch(target); -} - -function applyAction(id: string, body: AllocationIn): void { - const found = locate(id, body.side); - // «Не учитывать в КФО» — только у нераспределённой строки; строки агрегата учитываются всегда - const flag = (excluded: boolean) => { - if (found.where !== 'unallocated') fail(422, 'ENTRY_ALLOCATED', 'Строка входит в агрегат и учитывается в КФО'); - else found.item.excluded = excluded; - }; - switch (body.action) { - case 'EXCLUDE': - flag(true); - break; - case 'INCLUDE': - flag(false); - break; - case 'ASSIGN': - if (!body.rule_id) fail(422, 'RULE_REQUIRED', 'Для ASSIGN нужно правило'); - assign(found, body.rule_id as string); - break; - case 'UNASSIGN': { - if (found.where === 'unallocated') break; - const moved = detach(found); - state.unallocated.push({ - entry_ids: moved.entry_ids, - owner: moved.owner, - counterparty: moved.counterparty, - account_code: moved.account_code, - line_code: moved.line_code, - side: moved.side, - amount: moved.amount, - candidate_rule_ids: moved.candidates, - reason: 'UNASSIGNED', - excluded: false, - }); - break; - } - default: // RESET - if (found.where === 'unallocated') found.item.excluded = false; - else { - found.entry.decision = 'AUTO'; - touch(found.aggregate); - } - break; - } -} - -export const correctionsFake = { - getCorrectionTable(_versionId: string): Promise { - return Promise.resolve(snapshot()); - }, - - saveCorrection(_versionId: string, key: AggregateKey, body: CorrectionIn): Promise { - return run(() => { - const a = aggregateByKey(key); - if (body.comment.trim() === '') fail(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'); - const current = a.correction?.version_no ?? null; - if (body.expected_version_no !== current) fail(409, 'CORRECTION_VERSION_CONFLICT', 'Корректировку уже изменили, обновите таблицу'); - a.correction = { - status: 'APPLIED', - version_no: (current ?? 0) + 1, - debit: clone(body.debit), - credit: clone(body.credit), - comment: body.comment.trim(), - reason_code: body.reason_code ?? null, - balance: { - debit_total: '0.00', - credit_total: '0.00', - difference: '0.00', - difference_percent: null, - remainder_1: '0.00', - remainder_2: '0.00', - }, - updated_by: { id: 'u-mock', name: 'Иванов И. И.' }, - updated_at: new Date().toISOString(), - }; - a.composition_changed = false; - recomputeAggregate(a); - return clone(a); - }); - }, - - allocate(_versionId: string, body: AllocationIn): Promise { - return run(() => { - if (!body.entry_ids.length) fail(422, 'VALIDATION_ERROR', 'Не указаны строки'); - // всё или ничего: при ошибке на любой строке возвращаем состояние, каким оно было - 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([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), - filename: 'corrections-mock.csv', - }); - }, -}; - -/** Синхронные проверки превращаем в отклонённый Promise, как у настоящего запроса. */ -function run(fn: () => T): Promise { - try { - return Promise.resolve(fn()); - } catch (e) { - return Promise.reject(e); - } -}