Сторона позиции в решении по строке (#206)

AllocationIn получил side: строка с дебетовым и кредитовым сальдо даёт
две позиции, решение касается одной. Флажок, меню строки, окно
корректировки и фейк передают и ищут позицию по строке и стороне.
Контракт — docs PR #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 22:36:02 +04:00
parent 753eccbd97
commit 4db04f096a
7 changed files with 86 additions and 29 deletions

View File

@ -47,7 +47,7 @@ describe('correctionsFake', () => {
const before = find(table0, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"');
const row = before.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, action: 'EXCLUDE' });
const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' });
const after = find(t, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"');
expect(after.side_1.amount).toBe('1325000.00');
expect(after.side_1.entries.find((e) => e.account_code === '62.03')?.excluded).toBe(true);
@ -58,7 +58,12 @@ describe('correctionsFake', () => {
it('EXCLUDE в агрегате с корректировкой отмечает смену состава', async () => {
const t0 = await fake.getCorrectionTable(V);
const ept = find(t0, 'ЭПТ');
const t = await fake.allocate(V, { statement_type: 'balance', entry_ids: ept.side_1.entries[0].entry_ids, action: 'EXCLUDE' });
const t = await fake.allocate(V, {
statement_type: 'balance',
entry_ids: ept.side_1.entries[0].entry_ids,
side: 'D',
action: 'EXCLUDE',
});
expect(find(t, 'ЭПТ').composition_changed).toBe(true);
});
@ -67,7 +72,7 @@ describe('correctionsFake', () => {
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, action: 'ASSIGN', rule_id: 'r1' }),
fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'ASSIGN', rule_id: 'r1' }),
422,
'RULE_NOT_APPLICABLE',
);
@ -76,7 +81,7 @@ describe('correctionsFake', () => {
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, action: 'ASSIGN', rule_id: 'r7' });
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');
@ -85,22 +90,40 @@ describe('correctionsFake', () => {
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' });
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, action: 'ASSIGN' }), 422, 'RULE_REQUIRED');
await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: ['нет'], action: 'EXCLUDE' }), 422, 'ENTRY_NOT_IN_VERSION');
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, 'нет'], action: 'EXCLUDE' }),
fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], side: t.unallocated[0].side, action: 'EXCLUDE' }),
422,
'ENTRY_NOT_IN_VERSION',
);
@ -111,7 +134,13 @@ describe('correctionsFake', () => {
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 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');
@ -122,11 +151,11 @@ describe('correctionsFake', () => {
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' });
await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' });
const back = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, 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' });
await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'EXCLUDE' });
const reset = await fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, side: row.side, action: 'RESET' });
expect(find(reset, 'ООО "БЕКОВСКИЙ САХАРНЫЙ ЗАВОД"').side_1.entries[0].excluded).toBe(false);
});

View File

@ -10,6 +10,7 @@ import type {
CorrectionRevokeIn,
CorrectionTableOut,
Ref,
Side,
UnallocatedEntryOut,
} from '../../types/corrections';
import { computeTotals, recomputeAggregate } from '../../widgets/CorrectionTable/mockCalc';
@ -49,16 +50,17 @@ type Located =
| { where: 'aggregate'; aggregate: CorrectionAggregateOut; sideNo: 1 | 2; entry: CorrectionEntryOut }
| { where: 'unallocated'; item: UnallocatedEntryOut };
function locate(id: string): Located {
/** Позиция — строка свода и сторона: у строки с обоими сальдо две позиции, решение касается одной. */
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.entry_ids.includes(id));
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.entry_ids.includes(id));
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', 'Строки нет среди строк версии');
return fail(422, 'ENTRY_NOT_IN_VERSION', 'Позиции нет среди строк версии');
}
function refOf(a: CorrectionAggregateOut, id: string): Ref {
@ -152,7 +154,7 @@ function assign(found: Located, ruleId: string): void {
}
function applyAction(id: string, body: AllocationIn): void {
const found = locate(id);
const found = locate(id, body.side);
const flag = (excluded: boolean) => {
if (found.where === 'unallocated') found.item.excluded = excluded;
else {

View File

@ -163,6 +163,8 @@ export type AllocationAction = 'ASSIGN' | 'UNASSIGN' | 'EXCLUDE' | 'INCLUDE' | '
export interface AllocationIn {
statement_type: StatementType;
entry_ids: string[];
/** Позиция строки: строка с дебетовым и кредитовым сальдо даёт две позиции, решение — по одной из них. */
side: Side;
action: AllocationAction;
/** Обязателен для ASSIGN. */
rule_id?: string | null;

View File

@ -188,10 +188,17 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
}
};
const unassign = (e: CorrectionEntryOut) => runAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, action: 'UNASSIGN' });
const unassign = (e: CorrectionEntryOut) =>
runAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, side: e.side, action: 'UNASSIGN' });
const assign = async (u: UnallocatedEntryOut) => {
if (!aggregate) return;
const ok = await runAllocate({ statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: aggregate.rule_id });
const ok = await runAllocate({
statement_type: 'balance',
entry_ids: u.entry_ids,
side: u.side,
action: 'ASSIGN',
rule_id: aggregate.rule_id,
});
if (ok) setPickerSide(null);
};

View File

@ -212,7 +212,7 @@ export function CorrectionTable(props: Props) {
disabled={!canAct}
checked={!e.excluded}
aria-label={`Учитывать в КФО: ${e.account_code}`}
onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, ev.target.checked))}
onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, e.side, ev.target.checked))}
/>,
);
case 'd_entity':

View File

@ -95,15 +95,32 @@ describe('includeBody / menuBody', () => {
const un: TableRow = { kind: 'unallocated', key: 'u', entry: t.unallocated[0] };
it('флажок: поставлен — INCLUDE, снят — EXCLUDE, entry_ids из строки', () => {
expect(includeBody(entry.entry_ids, true)).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'INCLUDE' });
expect(includeBody(entry.entry_ids, false).action).toBe('EXCLUDE');
expect(includeBody(entry.entry_ids, entry.side, true)).toEqual({
statement_type: 'balance',
entry_ids: entry.entry_ids,
side: entry.side,
action: 'INCLUDE',
});
expect(includeBody(entry.entry_ids, entry.side, false).action).toBe('EXCLUDE');
});
it('ASSIGN с правилом из ключа подменю', () => {
expect(menuBody(row, 'ASSIGN:r6')).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'ASSIGN', rule_id: 'r6' });
expect(menuBody(row, 'ASSIGN:r6')).toEqual({
statement_type: 'balance',
entry_ids: entry.entry_ids,
side: entry.side,
action: 'ASSIGN',
rule_id: 'r6',
});
expect(menuBody(un, 'ASSIGN:r7')?.entry_ids).toEqual(t.unallocated[0].entry_ids);
expect(menuBody(un, 'ASSIGN:r7')?.side).toBe(t.unallocated[0].side);
});
it('UNASSIGN и RESET без rule_id', () => {
expect(menuBody(row, 'UNASSIGN')).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'UNASSIGN' });
expect(menuBody(row, 'UNASSIGN')).toEqual({
statement_type: 'balance',
entry_ids: entry.entry_ids,
side: entry.side,
action: 'UNASSIGN',
});
expect(menuBody(row, 'RESET')?.action).toBe('RESET');
});
it('родитель подменю, пустое правило, неизвестный ключ и агрегат — запроса нет', () => {

View File

@ -1,6 +1,6 @@
// Чистые функции для режима «только чтение» и меню решений по строке (без React — тестируются отдельно).
import { ApiError } from '../../api/client';
import type { AllocationAction, AllocationIn, CorrectionRuleOut, ReadOnlyReason } from '../../types/corrections';
import type { AllocationAction, AllocationIn, CorrectionRuleOut, ReadOnlyReason, Side } from '../../types/corrections';
import type { TableRow } from './tableModel';
/** Подсказки для режима «только чтение» (дословно из спеки). */
@ -41,8 +41,8 @@ export const COMMENT_MAX_LENGTH = 1000;
export const AGGREGATE_FLAG_HINT = 'Показывает состояние строк; менять — в раскрытии';
/** Тело запроса для флажка «Учитывать в КФО» строки: поставлен — INCLUDE, снят — EXCLUDE. */
export function includeBody(entryIds: string[], checked: boolean): AllocationIn {
return { statement_type: 'balance', entry_ids: entryIds, action: checked ? 'INCLUDE' : 'EXCLUDE' };
export function includeBody(entryIds: string[], side: Side, checked: boolean): AllocationIn {
return { statement_type: 'balance', entry_ids: entryIds, side, action: checked ? 'INCLUDE' : 'EXCLUDE' };
}
/**
@ -51,7 +51,7 @@ export function includeBody(entryIds: string[], checked: boolean): AllocationIn
*/
export function menuBody(row: TableRow, key: string): AllocationIn | null {
if (row.kind === 'aggregate') return null;
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids };
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids, side: row.entry.side };
if (key.startsWith('ASSIGN:')) {
const ruleId = key.slice('ASSIGN:'.length);
return ruleId ? { ...base, action: 'ASSIGN', rule_id: ruleId } : null;