api.del: поддержка тела запроса
Нужно для DELETE .../vgo-assignments/rule (снятие правила) — API ждёт statement_type/entry_ids в теле. Единственный прежний вызов (files.ts, без тела) не затронут.
This commit is contained in:
parent
c2bf047cb3
commit
b77c62816f
@ -148,5 +148,5 @@ export const api = {
|
|||||||
post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)),
|
post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)),
|
||||||
patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', 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)),
|
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)),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user