Andrey Epifancev 733d7a9036 fix(denomination): база разрядности — тыс. руб., масштабировать только денежные колонки
Селектор разрядности (AURORA-1650) считал базой рубли, хотя в базе суммы
хранятся в тыс. руб. (формы 1/4, сателлиты формы 2, лимитные строки АХР
после AURORA-1897). По умолчанию стояло «в руб.», и тысячи показывались
под видом рублей; при выборе «в тыс. руб.» значения делились ещё раз.
Делитель также применялся к количествам, площади, ставке НДС, курсу,
кодам и ID — не только к деньгам. Редактор ячейки не учитывал разрядность.

- web/src/components/RealtimeTable/utils/moneyUtils.js — новый модуль:
  UNIT_IN_RUB, getDisplayFactor(baseUnit, denomination), getMoneyUnit
  (денежная единица колонки по accessorKey/sheetName, с явным
  columnDef.moneyUnit как переопределением), TEXT_COLUMN_IDS (перенесён
  из Cell.jsx), resolveDenomination/DEFAULT_DENOMINATION, AHR_LIMIT_SHEET.
  Модуль не импортирует конфиги форм — работает под node --test.
- useDenomination.js — значение по умолчанию 'thousand', ключ хранения
  со суффиксом _denomination_v2 (старые 'rub'/'thousand' больше не
  читаются со старым смыслом); getDenominationDivisor убран.
- tableColumns.jsx/Cell.jsx — displayDivisor заменён на displayFactor
  (умножение, не деление), множитель считается на колонку через
  getMoneyUnit/getDisplayFactor с учётом sheetName; isEditable запрещает
  редактирование денежной ячейки, если разрядность не базовая; на листе
  AHR_LIMIT множитель всегда 1 (значения в рублях, как есть).
- SettingPanel.jsx — селектор скрыт на листе AHR_LIMIT, рядом с ним
  подсказка «Ввод сумм — в тыс. руб.».
- SvodPage/Svod2Page/ProjectSummariesPage — масштабирование по
  meta.money вместо regex по id/блок-листа текстовых id; построители
  колонок проставляют meta.money на денежных метриках.
- DenominationSelect.jsx — «в млн руб.», «в млрд руб.» без точки.

Тесты: web/src/components/RealtimeTable/utils/moneyUtils.test.js
(node --test), полный набор web/src/components/RealtimeTable/utils —
52/52 зелёных. npm run build проходит.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-01 16:08:20 +04:00

311 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
import { SummaryDenominationControl, SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from './SummaryTable';
import { useSvod } from './SvodContext';
import { SvodFilters } from './SvodFilters';
const SHEETS = [
{ value: 'MAIN', label: 'Общий свод' },
{ value: 'FORM_1', label: 'Смета ССП' },
{ value: 'FORM_2', label: 'Смета РФ' },
{ value: 'FORM_3', label: 'Проекты РФ Развитие' },
{ value: 'FORM_4', label: 'Проектная деятельность' },
];
const METRICS = [
{ key: 'plan', label: 'План' },
{ key: 'approved', label: 'Утверждено' },
{ key: 'fact', label: 'Факт' },
{ key: 'corrected', label: 'Скорректированный план' },
];
const FORM_3_METRIC_LABELS = {
plan: 'Расходы, планируемые РФ',
approved: 'Утвержденные базовые расходы',
fact: 'Фактические расходы',
corrected: 'Скорректированная смета расходов',
};
const DIRECTIONS = [
{ key: 'support', label: 'Поддержка' },
{ key: 'development', label: 'Развитие' },
];
const QUARTERS = [
{ key: 'q1', label: 'I кв.' },
{ key: 'q2', label: 'II кв.' },
{ key: 'q3', label: 'III кв.' },
{ key: 'q4', label: 'IV кв.' },
{ key: 'year', label: 'Год' },
];
const FORM_3_QUARTERS = [
{ key: 'q1', label: 'I квартал' },
{ key: 'q2', label: 'II квартал' },
{ key: 'q3', label: 'III квартал' },
{ key: 'q4', label: 'IV квартал' },
{ key: 'year', label: 'ИТОГО Развитие' },
];
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data);
const tableSize = (rem) => rem * 16;
const formatValue = (value) => {
if (value === null || value === undefined || value === '') return '—';
const numericValue = typeof value === 'number' ? value : Number(value);
if (numericValue === 0) return '-';
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue);
};
const getShapeForSheet = (sheet) => {
const directionShape = { q1: 0, q2: 0, q3: 0, q4: 0, year: 0 };
const correctedShape = { q2: 0, q3: 0, q4: 0 };
return {
plan: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
approved: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
fact: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
corrected: { support: correctedShape, development: correctedShape },
};
};
const valueColumn = (metric, direction, period) => ({
id: `${metric.key}.${direction.key}.${period.key}`,
header: period.label,
accessorFn: (row) => valueAt(row, [metric.key, direction.key, period.key]),
Cell: ({ cell }) => formatValue(cell.getValue()),
size: tableSize(period.key === 'year' ? 8 : 6.75),
muiTableBodyCellProps: { align: 'right' },
muiTableHeadCellProps: { align: 'right' },
meta: { money: true },
});
const createForm3MetricColumns = (metric) => ({
id: metric.key,
header: FORM_3_METRIC_LABELS[metric.key],
columns: FORM_3_QUARTERS.filter(({ key }) =>
valueAt({ data: getShapeForSheet('FORM_3') }, [metric.key, 'development', key]) !== undefined,
).map((period) => ({
...valueColumn(metric, { key: 'development' }, period),
id: metric.key + '.' + period.key,
})),
});
const createColumns = (sheet) => {
let visibleDirections = DIRECTIONS;
if (sheet === 'FORM_2') visibleDirections = DIRECTIONS.filter(({ key }) => key === 'support');
if (['FORM_3', 'FORM_4'].includes(sheet)) visibleDirections = DIRECTIONS.filter(({ key }) => key === 'development');
const shape = getShapeForSheet(sheet);
if (sheet === 'FORM_3') {
return [
{ id: 'section_code', header: '№ п/п', accessorFn: (row) => row.data?.section_code, size: tableSize(5.75) },
{
id: 'name',
header: 'Перечень расходов и затрат',
accessorFn: (row) => row.data?.name,
size: tableSize(20.625),
Cell: ({ cell, row }) => (
<Box sx={{ pl: row.original.depth * 1.125 + 'rem', fontWeight: row.original.depth < 2 ? 600 : 400 }}>
{cell.getValue() || 'Без названия'}
</Box>
),
},
...METRICS.map(createForm3MetricColumns),
];
}
const metricColumns = METRICS.map((metric) => ({
id: metric.key,
header: sheet === 'FORM_3' ? FORM_3_METRIC_LABELS[metric.key] : metric.label,
columns: visibleDirections
.map((direction) => ({
id: `${metric.key}.${direction.key}`,
header: direction.label,
columns: QUARTERS.filter(({ key }) => valueAt({ data: shape }, [metric.key, direction.key, key]) !== undefined).map((period) =>
valueColumn(metric, direction, period),
),
}))
.concat(
shape[metric.key].total_year === undefined
? []
: [
{
id: `${metric.key}.total`,
header: 'Итого',
columns: [
{
id: `${metric.key}.total_year`,
header: 'Год',
accessorFn: (row) => valueAt(row, [metric.key, 'total_year']),
Cell: ({ cell }) => formatValue(cell.getValue()),
size: tableSize(8.25),
muiTableBodyCellProps: { align: 'right' },
muiTableHeadCellProps: { align: 'right' },
meta: { money: true },
},
],
},
],
),
}));
return [
{ id: 'section_code', header: 'Код', accessorFn: (row) => row.data?.section_code, size: tableSize(5.75) },
{
id: 'name',
header: 'Статья расходов',
accessorFn: (row) => row.data?.name,
size: tableSize(20.625),
Cell: ({ cell, row }) => (
<Box sx={{ pl: `${row.original.depth * 1.125}rem`, fontWeight: row.original.depth < 2 ? 600 : 400 }}>
{cell.getValue() || 'Без названия'}
</Box>
),
},
...metricColumns,
];
};
export default function SvodPage() {
const navigate = useNavigate();
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
const [sheet, setSheet] = useState('MAIN');
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const latestSummaryRequestRef = useRef(0);
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
const updateCurrentSheetFilters = useCallback(
(changes) => {
updateSheetFilters(sheet, changes);
},
[sheet, updateSheetFilters],
);
const loadSummary = useCallback(async () => {
const requestId = latestSummaryRequestRef.current + 1;
latestSummaryRequestRef.current = requestId;
setLoading(true);
setError('');
try {
const response = await getSummary(
sheet,
year,
selectedOrganizations.map(({ id }) => id),
);
if (latestSummaryRequestRef.current !== requestId) return;
setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)));
} catch (loadError) {
if (latestSummaryRequestRef.current !== requestId) return;
setRows([]);
setError(loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.');
} finally {
if (latestSummaryRequestRef.current === requestId) setLoading(false);
}
}, [getSummary, selectedOrganizations, sheet, year]);
useEffect(() => {
loadSummary();
return () => {
latestSummaryRequestRef.current += 1;
};
}, [loadSummary]);
const refreshSummary = useCallback(() => {
invalidateSummary(
sheet,
year,
selectedOrganizations.map(({ id }) => id),
);
loadSummary();
}, [invalidateSummary, loadSummary, selectedOrganizations, sheet, year]);
const openFilteredTasks = useCallback(() => {
const params = new URLSearchParams({ year: String(year) });
const organizationIds = selectedOrganizations.map(({ id }) => id);
if (organizationIds.length > 0) params.set('org_units', organizationIds.join(','));
if (sheet !== 'MAIN') params.set('form_type', sheet);
navigate(`/tasks?${params.toString()}`);
}, [navigate, selectedOrganizations, sheet, year]);
const openFilteredProjects = useCallback(() => {
const params = new URLSearchParams();
const organizationIds = selectedOrganizations.map(({ id }) => id);
if (organizationIds.length > 0) params.set('branch_ids', organizationIds.join(','));
const query = params.toString();
navigate(query ? `/projects?${query}` : '/projects');
}, [navigate, selectedOrganizations]);
const tree = useMemo(() => buildSummaryTree(rows), [rows]);
const filteredRows = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return normalizedSearch ? filterSummaryTree(tree, normalizedSearch) : tree;
}, [tree, search]);
const columns = useMemo(() => createColumns(sheet), [sheet]);
const filteredRowsCount = useMemo(() => countSummaryTree(filteredRows), [filteredRows]);
return (
<Box
sx={{
px: '2rem',
pt: '1rem',
pb: '1.5rem',
height: '100%',
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}>
<HeaderSwitch />
<Paper elevation={0} sx={{ border: '0.0625rem solid #e5e7eb', borderRadius: '0.75rem', overflow: 'hidden', flexShrink: 0 }}>
<Tabs
value={sheet}
onChange={(_event, value) => setSheet(value)}
variant='scrollable'
scrollButtons='auto'
sx={{ px: '1rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
{SHEETS.map((item) => (
<Tab key={item.value} value={item.value} label={item.label} sx={{ textTransform: 'none', alignItems: 'flex-start', textAlign: 'left', minHeight: '3rem' }} />
))}
</Tabs>
<SvodFilters year={year} selectedOrganizations={selectedOrganizations} search={search} onChange={updateCurrentSheetFilters} additionalFilters={<SummaryDenominationControl tableKey={`svod_${sheet}`} />}>
{!loading && filteredRowsCount !== rows.length && (
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
{`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
</Typography>
)}
{sheet === 'FORM_3' ? (
<PrimaryButton variant='contained' onClick={openFilteredProjects} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Перейти к проектам
</PrimaryButton>
) : (
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Перейти к задачам
</PrimaryButton>
)}
<PrimaryOutlinedButton
variant='outlined'
color='default'
startIcon={<RefreshRoundedIcon />}
disabled={loading}
onClick={refreshSummary}
sx={{ height: '2.5rem', flexShrink: 0 }}>
Обновить
</PrimaryOutlinedButton>
</SvodFilters>
</Paper>
{error ? (
<Alert
severity='error'
action={
<Button color='inherit' size='small' onClick={refreshSummary}>
Повторить
</Button>
}>
{error}
</Alert>
) : (
<SummaryTable columns={columns} data={filteredRows} loading={loading} tableKey={`svod_${sheet}`} />
)}
</Box>
);
}