AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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 = ' ';
|
||||
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 при ненулевой другой. */
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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)}` };
|
||||
}
|
||||
|
||||
|
||||
@ -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'])], []);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user