From 4db04f096a12fe4dffb8c311584f51dc5ca72293 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:36:02 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B5=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllocationIn получил side: строка с дебетовым и кредитовым сальдо даёт две позиции, решение касается одной. Флажок, меню строки, окно корректировки и фейк передают и ищут позицию по строке и стороне. Контракт — docs PR #184. Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/correctionsFake.test.ts | 55 ++++++++++++++----- web/src/services/api/correctionsFake.ts | 12 ++-- web/src/types/corrections.ts | 2 + .../CorrectionTable/CorrectionEditorModal.tsx | 11 +++- .../CorrectionTable/CorrectionTable.tsx | 2 +- .../widgets/CorrectionTable/readOnly.test.ts | 25 +++++++-- web/src/widgets/CorrectionTable/readOnly.ts | 8 +-- 7 files changed, 86 insertions(+), 29 deletions(-) diff --git a/web/src/services/api/correctionsFake.test.ts b/web/src/services/api/correctionsFake.test.ts index ea83656..66ff6e8 100644 --- a/web/src/services/api/correctionsFake.test.ts +++ b/web/src/services/api/correctionsFake.test.ts @@ -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); }); diff --git a/web/src/services/api/correctionsFake.ts b/web/src/services/api/correctionsFake.ts index d4bc81c..c02e2d0 100644 --- a/web/src/services/api/correctionsFake.ts +++ b/web/src/services/api/correctionsFake.ts @@ -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 { diff --git a/web/src/types/corrections.ts b/web/src/types/corrections.ts index 5b1365f..f72cff4 100644 --- a/web/src/types/corrections.ts +++ b/web/src/types/corrections.ts @@ -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; diff --git a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx index 96fd51d..4a55e00 100644 --- a/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx @@ -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); }; diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx index 3d92160..e6d56fb 100644 --- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx @@ -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': diff --git a/web/src/widgets/CorrectionTable/readOnly.test.ts b/web/src/widgets/CorrectionTable/readOnly.test.ts index c95f394..293ab95 100644 --- a/web/src/widgets/CorrectionTable/readOnly.test.ts +++ b/web/src/widgets/CorrectionTable/readOnly.test.ts @@ -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('родитель подменю, пустое правило, неизвестный ключ и агрегат — запроса нет', () => { diff --git a/web/src/widgets/CorrectionTable/readOnly.ts b/web/src/widgets/CorrectionTable/readOnly.ts index 3a26b68..794cae4 100644 --- a/web/src/widgets/CorrectionTable/readOnly.ts +++ b/web/src/widgets/CorrectionTable/readOnly.ts @@ -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;