api.del: поддержка тела запроса

Нужно для DELETE .../vgo-assignments/rule (снятие правила) — API
ждёт statement_type/entry_ids в теле. Единственный прежний вызов
(files.ts, без тела) не затронут.
This commit is contained in:
Andrey Epifancev 2026-11-02 21:08:02 +04:00
parent c2bf047cb3
commit b77c62816f
3 changed files with 1 additions and 497 deletions

View File

@ -148,5 +148,5 @@ export const api = {
post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)),
patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', body)),
put: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PUT', body)),
del: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
del: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('DELETE', body)),
};

View File

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

View File

@ -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 = <T>(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<CorrectionTableOut> {
return Promise.resolve(snapshot());
},
saveCorrection(_versionId: string, key: AggregateKey, body: CorrectionIn): Promise<CorrectionAggregateOut> {
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<CorrectionTableOut> {
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<T>(fn: () => T): Promise<T> {
try {
return Promise.resolve(fn());
} catch (e) {
return Promise.reject(e);
}
}