AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215
@ -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)),
|
||||
};
|
||||
|
||||
@ -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