AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215

Merged
aep merged 29 commits from feat/206-corrections-ui into main 2026-11-02 20:12:17 +03:00
17 changed files with 260 additions and 75 deletions
Showing only changes of commit b669e00efe - Show all commits

View File

@ -66,7 +66,11 @@ 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('нет строки');
await expectCode(fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r1' }), 422, 'RULE_NOT_APPLICABLE');
await expectCode(
fake.allocate(V, { statement_type: 'balance', entry_ids: row.entry_ids, action: 'ASSIGN', rule_id: 'r1' }),
422,
'RULE_NOT_APPLICABLE',
);
});
it('ASSIGN нераспределённой строки в правило создаёт агрегат, UNASSIGN возвращает строку', async () => {
@ -95,7 +99,11 @@ describe('correctionsFake', () => {
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' }), 422, 'ENTRY_NOT_IN_VERSION');
await expectCode(
fake.allocate(V, { statement_type: 'balance', entry_ids: [first, 'нет'], action: 'EXCLUDE' }),
422,
'ENTRY_NOT_IN_VERSION',
);
expect((await fake.getCorrectionTable(V)).unallocated[0].excluded).toBe(false);
});

View File

@ -65,11 +65,29 @@ 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; side: 'D' | 'K'; amount: string; entry_ids: string[]; candidates: string[]; excluded: boolean } {
function detach(found: Located): {
owner: Ref;
counterparty: Ref;
account_code: string;
side: 'D' | 'K';
amount: string;
entry_ids: string[];
candidates: string[];
excluded: boolean;
} {
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, side: u.side, amount: u.amount, entry_ids: u.entry_ids, candidates: u.candidate_rule_ids, excluded: u.excluded };
return {
owner: u.owner,
counterparty: u.counterparty,
account_code: u.account_code,
side: u.side,
amount: u.amount,
entry_ids: u.entry_ids,
candidates: u.candidate_rule_ids,
excluded: u.excluded,
};
}
const { aggregate: a, sideNo, entry: e } = found;
const side = sideNo === 1 ? a.side_1 : a.side_2;
@ -198,7 +216,14 @@ export const correctionsFake = {
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' },
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(),
};
@ -236,8 +261,13 @@ export const correctionsFake = {
// 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([`${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), filename: 'corrections-mock.csv' });
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',
});
},
};

View File

@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { THRESHOLD_PERCENT_HUNDREDTHS, formatAmount, formatScaledAmount, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from './money';
import {
THRESHOLD_PERCENT_HUNDREDTHS,
formatAmount,
formatScaledAmount,
fromCents,
parseAmountInput,
percentOfSmaller,
sumAmounts,
toCents,
} from './money';
const NBSP = ' ';

View File

@ -15,7 +15,16 @@ import { CorrectionToolbar } from './CorrectionToolbar';
import { RevokeModal } from './RevokeModal';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
import { errorOutcome, needsReload, readOnlyHint } from './readOnly';
import { EMPTY_FILTERS, type TableFilters, aggregateKey, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel';
import {
EMPTY_FILTERS,
type TableFilters,
aggregateKey,
buildRows,
filterTable,
lineOptions,
toAggregateKey,
visibleTotals,
} from './tableModel';
import { useCorrectionTable } from './useCorrectionTable';
const LOAD_ERROR_KEY = 'corrections-load-error';
@ -54,7 +63,11 @@ export function CorrectionContent({ versionId, card = null }: Props) {
description: error,
placement: 'bottomRight',
duration: 0,
btn: <Button size='small' onClick={reload}>Повторить</Button>,
btn: (
<Button size='small' onClick={reload}>
Повторить
</Button>
),
});
}, [error, notification, reload]);

View File

@ -8,7 +8,16 @@ import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Si
import { AllocationPicker } from './AllocationPicker';
import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide';
import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage';
import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, toCorrectionIn, validateLines } from './editorModel';
import {
type EditorState,
initialEditorState,
isDirty,
liveControl,
pickerCandidates,
rebaseState,
toCorrectionIn,
validateLines,
} from './editorModel';
import { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView';
import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly';
import { articleCodes, findAggregate, toAggregateKey } from './tableModel';
@ -195,7 +204,11 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
setCommentError(null);
setLineError(null);
try {
const out = await saveCorrection(versionId, toAggregateKey(aggregate), toCorrectionIn(validation.body, aggregate.correction?.version_no ?? null));
const out = await saveCorrection(
versionId,
toAggregateKey(aggregate),
toCorrectionIn(validation.body, aggregate.correction?.version_no ?? null),
);
clearDraft(key);
onAggregate(out);
onClose();
@ -223,7 +236,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
const errs = validation.ok ? {} : validation.errors;
const line = state[name][i];
return {
line_code: errs[`${name}.${i}.line_code`] ?? (lineError && line && line.line_code.trim() === lineError.lineCode ? lineError.message : undefined),
line_code:
errs[`${name}.${i}.line_code`] ??
(lineError && line && line.line_code.trim() === lineError.lineCode ? lineError.message : undefined),
amount: errs[`${name}.${i}.amount`],
};
};
@ -233,8 +248,10 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
? { 1: sideText(rule.side_1), 2: sideText(rule.side_1 === 'D' ? 'K' : 'D') }
: { 1: 'Дт', 2: 'Кт' };
const sideData = (n: SideNo) => {
const side = aggregate ? (n === 1 ? aggregate.side_1 : aggregate.side_2) : null;
const company = aggregate ? (n === 1 ? aggregate.company_1.name : aggregate.company_2.name) : null;
const first = n === 1;
const pick = <T,>(a: T, b: T): T => (first ? a : b);
const side = aggregate ? pick(aggregate.side_1, aggregate.side_2) : null;
const company = aggregate ? pick(aggregate.company_1, aggregate.company_2).name : null;
return {
company,
article: side ? articleCodes(side.article_line).join(' / ') : '',
@ -274,7 +291,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{hint && <Alert type='info' role='alert' showIcon title={hint} />}
{!aggregate && <Alert type='warning' role='alert' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
{!aggregate && (
<Alert type='warning' role='alert' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />
)}
{notice && (
<Alert
type={notice.kind === 'stale' ? 'warning' : 'info'}
@ -296,8 +315,22 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<SideHeader company={s1.company} sideLabel={sideLabels[1]} article={s1.article} />
<SideHeader company={s2.company} sideLabel={sideLabels[2]} article={s2.article} />
<OsvBlock article={s1.article} entries={s1.entries} editable={edit} busy={busy} onPick={() => setPickerSide(1)} onUnassign={unassign} />
<OsvBlock article={s2.article} entries={s2.entries} editable={edit} busy={busy} onPick={() => setPickerSide(2)} onUnassign={unassign} />
<OsvBlock
article={s1.article}
entries={s1.entries}
editable={edit}
busy={busy}
onPick={() => setPickerSide(1)}
onUnassign={unassign}
/>
<OsvBlock
article={s2.article}
entries={s2.entries}
editable={edit}
busy={busy}
onPick={() => setPickerSide(2)}
onUnassign={unassign}
/>
</div>
<ControlRow {...(source ?? { left: '—', middle: '—', right: '—', red: false })} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>

View File

@ -117,7 +117,12 @@ export function CorrectionTable(props: Props) {
case 'include':
return withHint(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Checkbox disabled aria-label='Учитывать в КФО (состояние агрегата)' checked={row.include !== 'Нет'} indeterminate={row.include === 'Частично'} />
<Checkbox
disabled
aria-label='Учитывать в КФО (состояние агрегата)'
checked={row.include !== 'Нет'}
indeterminate={row.include === 'Частично'}
/>
<span style={{ fontSize: 12 }}>{row.include}</span>
{a.composition_changed && (
<Tooltip title='Состав изменился после корректировки'>
@ -182,7 +187,13 @@ export function CorrectionTable(props: Props) {
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }],
onClick: () => actions.onRevoke(a),
}}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label={`Действия: ${a.company_1.name} — ${a.company_2.name}`} disabled={!canAct} />
<Button
size='small'
type='text'
icon={<MoreOutlined />}
aria-label={`Действия: ${a.company_1.name} — ${a.company_2.name}`}
disabled={!canAct}
/>
</Dropdown>,
)}
</span>
@ -196,7 +207,7 @@ export function CorrectionTable(props: Props) {
const e = row.entry;
const owned = sideSuffix(row); // 'D' | 'K'
const own = (side: 'D' | 'K') => owned === side;
switch (key) {
switch (key) {
case 'include':
return withHint(
<Checkbox
@ -232,10 +243,7 @@ export function CorrectionTable(props: Props) {
return own('K') ? format(e.amount) : null;
case 'actions':
return withHint(
<Dropdown
trigger={['click']}
disabled={!canAct}
menu={{ items: entryMenu(row), onClick: ({ key: k }) => onEntryMenu(row, k) }}>
<Dropdown trigger={['click']} disabled={!canAct} menu={{ items: entryMenu(row), onClick: ({ key: k }) => onEntryMenu(row, k) }}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label={`Действия: ${e.account_code}`} disabled={!canAct} />
</Dropdown>,
);
@ -255,7 +263,12 @@ export function CorrectionTable(props: Props) {
width,
ellipsis,
render: (_, row) => cell(key, row),
onCell: (row) => ({ style: { background: rowBg(row) ?? tint(key), opacity: (row.kind === 'entry' || row.kind === 'unallocated') && row.entry.excluded ? 0.5 : 1 } }),
onCell: (row) => ({
style: {
background: rowBg(row) ?? tint(key),
opacity: (row.kind === 'entry' || row.kind === 'unallocated') && row.entry.excluded ? 0.5 : 1,
},
}),
});
const filtersTitle = (label: string, active: boolean, body: ReactNode) => (
@ -368,7 +381,9 @@ export function CorrectionTable(props: Props) {
// колонка «Учитывать в КФО» в «Итого» занята подписью (colSpan 2 у первой ячейки), своей ячейки у неё нет
const leafKeys: (ColKey | 'expand')[] = [
'expand',
...COLUMN_DEFS.flatMap((d) => ('children' in d ? d.children.map((c) => c.key) : [d.key])).filter((k) => visible.has(k) && k !== 'include'),
...COLUMN_DEFS.flatMap((d) => ('children' in d ? d.children.map((c) => c.key) : [d.key])).filter(
(k) => visible.has(k) && k !== 'include',
),
];
return (

View File

@ -150,7 +150,12 @@ function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveL
disabled={!editable}
style={{ width: '100%' }}
onChange={(v) => onChangeLine(line.id, { line_code: v })}>
<Input maxLength={4} status={err.line_code ? 'error' : undefined} aria-label={`Строка баланса, ${where}`} aria-describedby={err.line_code ? codeErrId : undefined} />
<Input
maxLength={4}
status={err.line_code ? 'error' : undefined}
aria-label={`Строка баланса, ${where}`}
aria-describedby={err.line_code ? codeErrId : undefined}
/>
</AutoComplete>
{err.line_code && (
<div id={codeErrId} role='alert' style={{ color: '#d92d20', fontSize: 12 }}>
@ -176,7 +181,9 @@ function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveL
)}
</div>
<div>
{editable && <Button type='text' icon={<CloseOutlined />} aria-label={`Удалить статью, ${where}`} onClick={() => onRemoveLine(line.id)} />}
{editable && (
<Button type='text' icon={<CloseOutlined />} aria-label={`Удалить статью, ${where}`} onClick={() => onRemoveLine(line.id)} />
)}
</div>
</>
);
@ -185,7 +192,16 @@ function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveL
/** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */
export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) {
return (
<div aria-live='polite' style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '10px 12px', fontWeight: 600 }}>
<div
aria-live='polite'
style={{
...CARD,
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
alignItems: 'center',
padding: '10px 12px',
fontWeight: 600,
}}>
<span>{left}</span>
<span style={{ color: red ? '#d92d20' : undefined }}>{middle}</span>
<span style={{ textAlign: 'right' }}>{right}</span>

View File

@ -23,7 +23,7 @@ export function HeaderFilter({ label, active, children }: Props) {
style={{ cursor: 'pointer', border: 0, background: 'none', padding: 0 }}>
{active ? <FilterFilled style={{ color: '#2a8c3a' }} /> : <FilterOutlined style={{ color: '#98a2b3' }} />}
</button>
</Popover>
</Popover>
</span>
);
}

View File

@ -34,11 +34,7 @@ function isDraft(value: unknown): value is Draft {
const s = v.state as Record<string, unknown> | null;
if (typeof s !== 'object' || s === null) return false;
return (
Array.isArray(s.debit) &&
s.debit.every(isLine) &&
Array.isArray(s.credit) &&
s.credit.every(isLine) &&
typeof s.comment === 'string'
Array.isArray(s.debit) && s.debit.every(isLine) && Array.isArray(s.credit) && s.credit.every(isLine) && typeof s.comment === 'string'
);
}

View File

@ -1,15 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { CorrectionAggregateOut, CorrectionTableOut, UnallocatedEntryOut } from '../../types/corrections';
import type { EditorState } from './editorModel';
import {
initialEditorState,
isDirty,
liveControl,
pickerCandidates,
rebaseState,
toCorrectionIn,
validateLines,
} from './editorModel';
import { initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, toCorrectionIn, validateLines } from './editorModel';
import { makeMockTable } from './mockData';
import { aggregateKey } from './tableModel';
@ -116,13 +108,21 @@ describe('граница порога и пустые стороны', () => {
it('отклонение ровно 5.00 % — красный', () => {
const a = findBy(table(), 'ЭПТ');
// 100.00 к 105.00 — 5.00 % к меньшей
const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '105.00' }] });
const c = liveControl(a, {
...base,
debit: [{ line_code: '1230', amount: '100.00' }],
credit: [{ line_code: '1520', amount: '105.00' }],
});
expect(c.allocationDiff.percent).toBe('5.00');
expect(c.allocationDiff.red).toBe(true);
});
it('отклонение 4.99 % — не красный', () => {
const a = findBy(table(), 'ЭПТ');
const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '104.99' }] });
const c = liveControl(a, {
...base,
debit: [{ line_code: '1230', amount: '100.00' }],
credit: [{ line_code: '1520', amount: '104.99' }],
});
expect(c.allocationDiff.percent).toBe('4.99');
expect(c.allocationDiff.red).toBe(false);
});
@ -182,7 +182,13 @@ describe('toCorrectionIn', () => {
});
describe('pickerCandidates', () => {
const entry = (id: string, owner: { id: string; name: string }, cp: { id: string; name: string }, side: 'D' | 'K', extra: Partial<UnallocatedEntryOut> = {}): UnallocatedEntryOut => ({
const entry = (
id: string,
owner: { id: string; name: string },
cp: { id: string; name: string },
side: 'D' | 'K',
extra: Partial<UnallocatedEntryOut> = {},
): UnallocatedEntryOut => ({
entry_ids: [id],
owner,
counterparty: cp,

View File

@ -96,10 +96,12 @@ export function validateLines(s: EditorState): LinesValidation {
/** Сумма статей стороны для живого контроля: строки, не прошедшие parseLineAmount, в сумму не входят (их покажет validateLines). */
function sideSum(lines: EditLine[]): string | null {
return sumAmounts(lines.map((l) => {
const r = parseLineAmount(l.amount);
return r.ok ? r.value : null;
}));
return sumAmounts(
lines.map((l) => {
const r = parseLineAmount(l.amount);
return r.ok ? r.value : null;
}),
);
}
/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */

View File

@ -6,20 +6,30 @@ import { CONFLICT_MESSAGE, controlText, draftNotice, formatRub, pickLabel, saveE
const err = (status: number, code: string, detail: Record<string, unknown> = {}) =>
new ApiError(status, code, `msg ${code}`, { detail: { code, message: `msg ${code}`, ...detail } });
const agg = (versionNo: number | null) => ({ correction: versionNo === null ? null : { version_no: versionNo } }) as unknown as CorrectionAggregateOut;
const agg = (versionNo: number | null) =>
({ correction: versionNo === null ? null : { version_no: versionNo } }) as unknown as CorrectionAggregateOut;
describe('saveErrorOutcome', () => {
it('COMMENT_REQUIRED — у комментария, без перечитывания', () => {
expect(saveErrorOutcome(err(422, 'COMMENT_REQUIRED'))).toMatchObject({ kind: 'comment', reload: false });
});
it('INVALID_LINE_CODE — у статьи detail.line_code', () => {
expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE', { line_code: '9999' }))).toMatchObject({ kind: 'line', lineCode: '9999', reload: false });
expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE', { line_code: '9999' }))).toMatchObject({
kind: 'line',
lineCode: '9999',
reload: false,
});
});
it('INVALID_LINE_CODE без line_code в теле — статья не определена, окно не падает', () => {
expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE'))).toMatchObject({ kind: 'line', lineCode: null });
});
it('CORRECTION_VERSION_CONFLICT — фиксированный текст и перечитывание', () => {
expect(saveErrorOutcome(err(409, 'CORRECTION_VERSION_CONFLICT'))).toEqual({ kind: 'conflict', message: CONFLICT_MESSAGE, lineCode: null, reload: true });
expect(saveErrorOutcome(err(409, 'CORRECTION_VERSION_CONFLICT'))).toEqual({
kind: 'conflict',
message: CONFLICT_MESSAGE,
lineCode: null,
reload: true,
});
});
it('прочий 409 — текст сервера и перечитывание; 500 — без перечитывания', () => {
expect(saveErrorOutcome(err(409, 'LOCKED_BY_OTHER'))).toMatchObject({ kind: 'other', message: 'msg LOCKED_BY_OTHER', reload: true });

View File

@ -14,7 +14,8 @@ export const DISCARD_QUESTION = 'Есть несохранённые измен
export const formatRub = (value: string | null): string => formatScaledAmount(value, { scale: 0, precision: 2 });
/** Подпись кнопки «Выбрать» в списке нераспределённых: читалка экрана называет контрагента, счёт и сумму. */
export const pickLabel = (counterparty: string, account: string, amount: string): string => `Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`;
export const pickLabel = (counterparty: string, account: string, amount: string): string =>
`Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`;
/** «394.27» → «394,27%»; строка в число не переводится. */
export const percentText = (p: string): string => `${p.replace('.', ',')}%`;
@ -74,7 +75,8 @@ export type DraftNotice = { kind: 'restored' | 'stale'; text: string };
*/
export function draftNotice(savedAt: string | null, baseVersionNo: number | null, a: CorrectionAggregateOut | null): DraftNotice | null {
if (savedAt === null) return null;
if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' };
if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a))
return { kind: 'stale', text: 'Корректировку изменили после ваших правок' };
return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` };
}

View File

@ -75,7 +75,12 @@ const entry = (owner: Ref, cp: Ref, account: string, side: Side, amount: string,
};
};
const side = (articleLine: string, entries: CorrectionEntryOut[]): AggregateSideOut => ({ article_line: articleLine, accounts: [], amount: '0.00', entries });
const side = (articleLine: string, entries: CorrectionEntryOut[]): AggregateSideOut => ({
article_line: articleLine,
accounts: [],
amount: '0.00',
entries,
});
function aggregate(ruleNo: number, c1: Ref, c2: Ref, s1: CorrectionEntryOut[], s2: CorrectionEntryOut[]): CorrectionAggregateOut {
const r = MOCK_RULES[ruleNo - 1];
@ -108,19 +113,23 @@ const unallocated = (
reason: UnallocatedEntryOut['reason'] = 'MULTIPLE_RULES',
): UnallocatedEntryOut => {
entryNo += 1;
return { entry_ids: [`e${entryNo}`], owner, counterparty: cp, account_code: account, side: s, amount, candidate_rule_ids: candidates, reason, excluded: false };
return {
entry_ids: [`e${entryNo}`],
owner,
counterparty: cp,
account_code: account,
side: s,
amount,
candidate_rule_ids: candidates,
reason,
excluded: false,
};
};
/** Свежая копия таблицы из дизайна 23.10: у каждого вызова свои объекты, фейк и тесты друг друга не портят. */
export function makeMockTable(): CorrectionTableOut {
entryNo = 0;
const eptIls = aggregate(
6,
ept,
ils,
[entry(ept, ils, '62.01', 'D', '1000000.00')],
[entry(ils, ept, '60.01', 'K', '202320.00')],
);
const eptIls = aggregate(6, ept, ils, [entry(ept, ils, '62.01', 'D', '1000000.00')], [entry(ils, ept, '60.01', 'K', '202320.00')]);
eptIls.correction = {
status: 'APPLIED',
version_no: 1,
@ -128,7 +137,14 @@ export function makeMockTable(): CorrectionTableOut {
credit: [{ line_code: '1520', amount: '202320.00' }],
comment: 'Требуется сверка с контрагентом',
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' },
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: '2026-10-28T12:00:00+03:00',
};
@ -152,9 +168,21 @@ export function makeMockTable(): CorrectionTableOut {
],
);
const pmpRs = aggregate(3, pmp, rsg, [entry(pmp, rsg, '58.03', 'D', '29742550.00', ['r4'])], [entry(rsg, pmp, '67.03', 'K', '29242550.00')]);
const pmpRs = aggregate(
3,
pmp,
rsg,
[entry(pmp, rsg, '58.03', 'D', '29742550.00', ['r4'])],
[entry(rsg, pmp, '67.03', 'K', '29242550.00')],
);
const bscPmp = aggregate(6, bscOoo, pmpOoo, [entry(bscOoo, pmpOoo, '62.01', 'D', '310000.00', ['r7', 'r8'])], [entry(pmpOoo, bscOoo, '60.01', 'K', '300000.00')]);
const bscPmp = aggregate(
6,
bscOoo,
pmpOoo,
[entry(bscOoo, pmpOoo, '62.01', 'D', '310000.00', ['r7', 'r8'])],
[entry(pmpOoo, bscOoo, '60.01', 'K', '300000.00')],
);
const agro = aggregate(4, agroH, agroF, [entry(agroH, agroF, '58.03', 'D', '5000000.00', ['r3'])], []);

View File

@ -116,7 +116,10 @@ describe('includeBody / menuBody', () => {
describe('errorOutcome', () => {
it('ошибка API: текст сервера; 409 — перечитать', () => {
expect(errorOutcome(new ApiError(409, 'LOCK_REQUIRED', 'Возьмите блокировку'), 'запасной')).toEqual({ message: 'Возьмите блокировку', reload: true });
expect(errorOutcome(new ApiError(409, 'LOCK_REQUIRED', 'Возьмите блокировку'), 'запасной')).toEqual({
message: 'Возьмите блокировку',
reload: true,
});
});
it('422 — без перечитывания', () => {
expect(errorOutcome(new ApiError(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'), 'з').reload).toBe(false);

View File

@ -76,7 +76,14 @@ export function lineOptions(table: CorrectionTableOut): string[] {
}
export function isFiltered(f: TableFilters): boolean {
return f.display.mode !== 'all' || f.recordType !== 'all' || f.entity.trim() !== '' || f.line !== null || f.account.trim() !== '' || f.deviation !== 'all';
return (
f.display.mode !== 'all' ||
f.recordType !== 'all' ||
f.entity.trim() !== '' ||
f.line !== null ||
f.account.trim() !== '' ||
f.deviation !== 'all'
);
}
export function filterTable(
@ -119,7 +126,8 @@ export function buildRows(view: ReturnType<typeof filterTable>, expanded: Readon
[1, aggregate.side_1.entries],
[2, aggregate.side_2.entries],
] as const) {
for (const entry of entries) rows.push({ kind: 'entry', key: `${key}|${side}|${entry.entry_ids.join(',')}`, parentKey: key, side, entry });
for (const entry of entries)
rows.push({ kind: 'entry', key: `${key}|${side}|${entry.entry_ids.join(',')}`, parentKey: key, side, entry });
}
}
for (const entry of view.unallocated) rows.push({ kind: 'unallocated', key: `u|${entry.entry_ids.join(',')}`, entry });
@ -138,8 +146,12 @@ export function visibleTotals(aggregates: CorrectionAggregateOut[]): {
amount_1: sumAmounts(aggregates.map((a) => a.side_1.amount)),
amount_2: sumAmounts(aggregates.map((a) => a.side_2.amount)),
deviation_amount: sumAmounts(aggregates.map((a) => a.deviation_amount)),
correction_debit: aggregates.length ? (sumAmounts(aggregates.flatMap((a) => a.correction?.debit.map((l) => l.amount) ?? [])) ?? '0.00') : null,
correction_credit: aggregates.length ? (sumAmounts(aggregates.flatMap((a) => a.correction?.credit.map((l) => l.amount) ?? [])) ?? '0.00') : null,
correction_debit: aggregates.length
? (sumAmounts(aggregates.flatMap((a) => a.correction?.debit.map((l) => l.amount) ?? [])) ?? '0.00')
: null,
correction_credit: aggregates.length
? (sumAmounts(aggregates.flatMap((a) => a.correction?.credit.map((l) => l.amount) ?? [])) ?? '0.00')
: null,
};
}

View File

@ -75,7 +75,9 @@ export function useCorrectionTable(versionId: string): {
(a: CorrectionAggregateOut) => {
requests.next();
setLoading(false);
setTable((prev) => (prev ? { ...prev, aggregates: prev.aggregates.map((x) => (aggregateKey(x) === aggregateKey(a) ? a : x)) } : prev));
setTable((prev) =>
prev ? { ...prev, aggregates: prev.aggregates.map((x) => (aggregateKey(x) === aggregateKey(a) ? a : x)) } : prev,
);
},
[requests],
);