Сторона позиции в решении по строке (#206)
AllocationIn получил side: строка с дебетовым и кредитовым сальдо даёт две позиции, решение касается одной. Флажок, меню строки, окно корректировки и фейк передают и ищут позицию по строке и стороне. Контракт — docs PR #184. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
753eccbd97
commit
4db04f096a
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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('родитель подменю, пустое правило, неизвестный ключ и агрегат — запроса нет', () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user