Merge pull request 'AURORA-1899: база разрядности — тыс. руб., масштабировать только денежные колонки' (#288) from feat/285-aurora-1899-denomination-base-thousand into test
This commit is contained in:
commit
c55a14749a
@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { useCellLock } from '../../contexts/RealtimeContext';
|
import { useCellLock } from '../../contexts/RealtimeContext';
|
||||||
|
import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils';
|
||||||
|
|
||||||
const BASE_CELL_STYLES = {
|
const BASE_CELL_STYLES = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -81,17 +82,6 @@ const OVER_LIMIT_CHIP_STYLES = {
|
|||||||
|
|
||||||
const NAME_COLUMN_IDS = new Set(['data.header.name', 'data.name']);
|
const NAME_COLUMN_IDS = new Set(['data.header.name', 'data.name']);
|
||||||
|
|
||||||
const TEXT_COLUMN_IDS = new Set([
|
|
||||||
'data.header.num_group',
|
|
||||||
'data.nomenclature_group_id',
|
|
||||||
'data.header.item_id',
|
|
||||||
'data.article_id',
|
|
||||||
'data.code_razdela',
|
|
||||||
'data.header.num_group_id',
|
|
||||||
'data.section_no',
|
|
||||||
'data.expense_item_code',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getColorBrightness = (hexColor) => {
|
const getColorBrightness = (hexColor) => {
|
||||||
if (!hexColor) return 255;
|
if (!hexColor) return 255;
|
||||||
const color = hexColor.replace('#', '');
|
const color = hexColor.replace('#', '');
|
||||||
@ -154,7 +144,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => {
|
const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayFactor = 1 }) => {
|
||||||
const cellKey = `${row.id}_${column.id}`;
|
const cellKey = `${row.id}_${column.id}`;
|
||||||
const isLocked = useCellLock(cellKey);
|
const isLocked = useCellLock(cellKey);
|
||||||
|
|
||||||
@ -168,7 +158,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !TEXT_COLUMN_IDS.has(column.id);
|
const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !TEXT_COLUMN_IDS.has(column.id);
|
||||||
let display = String(value || '');
|
let display = String(value || '');
|
||||||
if (isNum) {
|
if (isNum) {
|
||||||
display = formatNumber(Number(display) / displayDivisor, false);
|
display = formatNumber(Number(display) * displayFactor, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVspDropdown && value != null) {
|
if (isVspDropdown && value != null) {
|
||||||
@ -179,7 +169,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { rawValue: value, displayValue: display, isNumeric: isNum };
|
return { rawValue: value, displayValue: display, isNumeric: isNum };
|
||||||
}, [cell, column.id, isVspDropdown, vspOptions, displayDivisor]);
|
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);
|
||||||
|
|
||||||
const highlightedContent = useMemo(() => {
|
const highlightedContent = useMemo(() => {
|
||||||
const searchQueries = [globalFilter, columnFilter];
|
const searchQueries = [globalFilter, columnFilter];
|
||||||
@ -241,7 +231,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
|||||||
prevProps.isInvalid === nextProps.isInvalid &&
|
prevProps.isInvalid === nextProps.isInvalid &&
|
||||||
prevProps.isEditable === nextProps.isEditable &&
|
prevProps.isEditable === nextProps.isEditable &&
|
||||||
prevProps.vspOptions === nextProps.vspOptions &&
|
prevProps.vspOptions === nextProps.vspOptions &&
|
||||||
prevProps.displayDivisor === nextProps.displayDivisor &&
|
prevProps.displayFactor === nextProps.displayFactor &&
|
||||||
prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit
|
prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -162,7 +162,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const columnVirtualizerRef = useRef(null);
|
const columnVirtualizerRef = useRef(null);
|
||||||
|
|
||||||
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
|
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
|
||||||
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination`;
|
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`;
|
||||||
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
|
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -265,12 +265,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
onCellUpdateError: handleCellUpdateError,
|
onCellUpdateError: handleCellUpdateError,
|
||||||
isCellInvalid,
|
isCellInvalid,
|
||||||
denomination,
|
denomination,
|
||||||
|
sheetName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating columns:', error);
|
console.error('Error creating columns:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]);
|
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination, sheetName]);
|
||||||
|
|
||||||
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
||||||
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Divider, IconButton, Stack, Tooltip } from '@mui/material';
|
import { Divider, IconButton, Stack, Tooltip, Typography } from '@mui/material';
|
||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
import { FORM_TYPE_OPTIONS } from '../../../constants/constants';
|
import { FORM_TYPE_OPTIONS } from '../../../constants/constants';
|
||||||
import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
|
import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
VisibleExpenseItems,
|
VisibleExpenseItems,
|
||||||
} from '../../common/icons/icons';
|
} from '../../common/icons/icons';
|
||||||
import { collectLeafColumns } from '../utils/columnUtils';
|
import { collectLeafColumns } from '../utils/columnUtils';
|
||||||
|
import { AHR_LIMIT_SHEET } from '../utils/moneyUtils';
|
||||||
import { GroupByObject } from './GroupByObject/GroupByObject';
|
import { GroupByObject } from './GroupByObject/GroupByObject';
|
||||||
import { Panel } from './SettingPanel.style';
|
import { Panel } from './SettingPanel.style';
|
||||||
import ValidationErrorsAlert from './ValidationErrorsAlert';
|
import ValidationErrorsAlert from './ValidationErrorsAlert';
|
||||||
@ -179,7 +180,14 @@ const SettingPanel = ({
|
|||||||
<Pin />
|
<Pin />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{sheetName !== AHR_LIMIT_SHEET && (
|
||||||
|
<>
|
||||||
<DenominationSelect value={denomination} onChange={onChangeDenomination} minWidth={130} />
|
<DenominationSelect value={denomination} onChange={onChangeDenomination} minWidth={130} />
|
||||||
|
<Typography variant='caption' color='text.secondary' sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
Ввод сумм — в тыс. руб.
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</GroupByObject>
|
</GroupByObject>
|
||||||
|
|
||||||
<Divider orientation='vertical' flexItem />
|
<Divider orientation='vertical' flexItem />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { useVspOptions } from './contexts/RealtimeContext';
|
import { useVspOptions } from './contexts/RealtimeContext';
|
||||||
import { getDenominationDivisor } from '../common/useDenomination';
|
import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils';
|
||||||
|
|
||||||
import { ROLES_NAME_ID } from '../../constants/constants';
|
import { ROLES_NAME_ID } from '../../constants/constants';
|
||||||
import { blueColumn } from './constants/columnColors';
|
import { blueColumn } from './constants/columnColors';
|
||||||
@ -119,17 +119,27 @@ export const getTableColumns = ({
|
|||||||
onCellUpdateError,
|
onCellUpdateError,
|
||||||
onCellNumberClick,
|
onCellNumberClick,
|
||||||
isCellInvalid,
|
isCellInvalid,
|
||||||
denomination = 'rub',
|
denomination = 'thousand',
|
||||||
|
sheetName,
|
||||||
}) => {
|
}) => {
|
||||||
const displayDivisor = getDenominationDivisor(denomination);
|
|
||||||
const columnColors = { ...columnsConfig.colors };
|
const columnColors = { ...columnsConfig.colors };
|
||||||
const columns = structuredClone(columnsConfig.columns);
|
const columns = structuredClone(columnsConfig.columns);
|
||||||
|
|
||||||
const cellPropsCache = new WeakMap();
|
const cellPropsCache = new WeakMap();
|
||||||
const editPropsCache = new WeakMap();
|
const editPropsCache = new WeakMap();
|
||||||
|
|
||||||
|
// factor === 1 означает «как в базе»: либо колонка не денежная, либо разрядность уже совпадает с базовой.
|
||||||
|
// На листе AHR_LIMIT нет селектора — значения всегда показываются как есть, независимо от сохранённой
|
||||||
|
// разрядности (там она по умолчанию 'thousand', а база листа — 'rub').
|
||||||
|
const getCellDisplayFactor = (column, moneyUnit = getMoneyUnit(column.columnDef, sheetName)) =>
|
||||||
|
moneyUnit && sheetName !== AHR_LIMIT_SHEET ? getDisplayFactor(moneyUnit, denomination) : 1;
|
||||||
|
|
||||||
const isEditable = (row, column) => {
|
const isEditable = (row, column) => {
|
||||||
if (row.original?.row_type !== 'INPUT') return false;
|
if (row.original?.row_type !== 'INPUT') return false;
|
||||||
|
const moneyUnit = getMoneyUnit(column.columnDef, sheetName);
|
||||||
|
// Разрядность, отличная от базовой «тыс. руб.», искажает значение — редактирование запрещено,
|
||||||
|
// чтобы случайно не записать домноженное/разделённое число.
|
||||||
|
if (moneyUnit !== null && getCellDisplayFactor(column, moneyUnit) !== 1) return false;
|
||||||
if (userRoleId === ROLES_NAME_ID.admin) return true;
|
if (userRoleId === ROLES_NAME_ID.admin) return true;
|
||||||
const keyCol = column.id;
|
const keyCol = column.id;
|
||||||
if (columnsCurStage.includes(keyCol)) return true;
|
if (columnsCurStage.includes(keyCol)) return true;
|
||||||
@ -142,6 +152,7 @@ export const getTableColumns = ({
|
|||||||
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
|
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
|
||||||
const isEditableCell = isEditable(row, column);
|
const isEditableCell = isEditable(row, column);
|
||||||
const backgroundColor = getColorCell(columnColors, column, row);
|
const backgroundColor = getColorCell(columnColors, column, row);
|
||||||
|
const displayFactor = getCellDisplayFactor(column);
|
||||||
if (!cellPropsCache.has(row)) {
|
if (!cellPropsCache.has(row)) {
|
||||||
cellPropsCache.set(row, {});
|
cellPropsCache.set(row, {});
|
||||||
}
|
}
|
||||||
@ -156,7 +167,7 @@ export const getTableColumns = ({
|
|||||||
cached.globalFilter === currentGlobalFilter &&
|
cached.globalFilter === currentGlobalFilter &&
|
||||||
cached.columnFilter === currentColumnFilter &&
|
cached.columnFilter === currentColumnFilter &&
|
||||||
cached.value === value &&
|
cached.value === value &&
|
||||||
cached.displayDivisor === displayDivisor &&
|
cached.displayFactor === displayFactor &&
|
||||||
cached.isInvalid === isInvalid &&
|
cached.isInvalid === isInvalid &&
|
||||||
cached.backgroundColor === backgroundColor
|
cached.backgroundColor === backgroundColor
|
||||||
) {
|
) {
|
||||||
@ -174,8 +185,8 @@ export const getTableColumns = ({
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
value,
|
value,
|
||||||
displayDivisor,
|
displayFactor,
|
||||||
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`,
|
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayFactor}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
rowCache[key] = props;
|
rowCache[key] = props;
|
||||||
@ -253,10 +264,13 @@ export const getTableColumns = ({
|
|||||||
size: 50,
|
size: 50,
|
||||||
enableEditing: false,
|
enableEditing: false,
|
||||||
Cell: ({ cell, row }) => {
|
Cell: ({ cell, row }) => {
|
||||||
const handleClick = useCallback((event) => {
|
const handleClick = useCallback(
|
||||||
|
(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onCellNumberClick(row.id);
|
onCellNumberClick(row.id);
|
||||||
}, [row.id, onCellNumberClick]);
|
},
|
||||||
|
[row.id, onCellNumberClick],
|
||||||
|
);
|
||||||
return <button type='button' onClick={handleClick}>{`${cell.getValue()} `}</button>;
|
return <button type='button' onClick={handleClick}>{`${cell.getValue()} `}</button>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
114
web/src/components/RealtimeTable/utils/moneyUtils.js
Normal file
114
web/src/components/RealtimeTable/utils/moneyUtils.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
// Денежные единицы и разрядность отображения (AURORA-1899).
|
||||||
|
//
|
||||||
|
// Правило: в базе денежные значения хранятся в тысячах рублей (кроме явных
|
||||||
|
// исключений ниже). Этот модуль не импортирует конфиги колонок форм, чтобы
|
||||||
|
// оставаться пригодным для `node --test` без сборки проекта.
|
||||||
|
|
||||||
|
// Единицы, доступные в селекторе разрядности, в рублях.
|
||||||
|
export const UNIT_IN_RUB = {
|
||||||
|
rub: 1,
|
||||||
|
thousand: 1e3,
|
||||||
|
million: 1e6,
|
||||||
|
billion: 1e9,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DENOMINATION = 'thousand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разрешает значение разрядности, прочитанное из хранилища (может быть
|
||||||
|
* null/undefined/'' при отсутствии сохранённого значения или значением из
|
||||||
|
* старой схемы хранения).
|
||||||
|
*/
|
||||||
|
export const resolveDenomination = (storedValue) => storedValue || DEFAULT_DENOMINATION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Множитель для перевода значения из базовой единицы колонки (`baseUnit`)
|
||||||
|
* в выбранную пользователем разрядность (`denomination`).
|
||||||
|
* displayedValue = baseValue * getDisplayFactor(baseUnit, denomination)
|
||||||
|
*/
|
||||||
|
export const getDisplayFactor = (baseUnit, denomination) => UNIT_IN_RUB[baseUnit] / UNIT_IN_RUB[denomination];
|
||||||
|
|
||||||
|
// Колонки, которые всегда текстовые (ID/коды), независимо от того, что значение похоже на число.
|
||||||
|
// Перенесено из Cell.jsx — там теперь только импорт.
|
||||||
|
export const TEXT_COLUMN_IDS = new Set([
|
||||||
|
'data.header.num_group',
|
||||||
|
'data.nomenclature_group_id',
|
||||||
|
'data.header.item_id',
|
||||||
|
'data.article_id',
|
||||||
|
'data.code_razdela',
|
||||||
|
'data.header.num_group_id',
|
||||||
|
'data.section_no',
|
||||||
|
'data.expense_item_code',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Последний сегмент accessorKey (после последней точки), который никогда не денежный:
|
||||||
|
// количества, ставки, курсы, коды, идентификаторы и т.п.
|
||||||
|
const NON_MONEY_LAST_SEGMENTS = new Set([
|
||||||
|
'qty_q1',
|
||||||
|
'qty_q2',
|
||||||
|
'qty_q3',
|
||||||
|
'qty_q4',
|
||||||
|
'rented_area',
|
||||||
|
'vat_rate',
|
||||||
|
'exchange_rate',
|
||||||
|
'amount_foreign',
|
||||||
|
'section',
|
||||||
|
'section_code',
|
||||||
|
'section_no',
|
||||||
|
'kod_razdela',
|
||||||
|
'item_id',
|
||||||
|
'num_group',
|
||||||
|
'num_group_id',
|
||||||
|
'internal_order',
|
||||||
|
'contract_number',
|
||||||
|
'vsp_id',
|
||||||
|
'pay_year',
|
||||||
|
'order',
|
||||||
|
'expense_item_code',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Лист "АХР лимиты" (FORM_2/AHR_LIMIT) — нормативы хранятся в рублях, а не в тыс. руб.
|
||||||
|
// У листа нет селектора разрядности (SettingPanel), значения показываются как есть.
|
||||||
|
export const AHR_LIMIT_SHEET = 'AHR_LIMIT';
|
||||||
|
const AHR_LIMIT_RUB_LAST_SEGMENTS = new Set([
|
||||||
|
'limit_with_vat',
|
||||||
|
'limit_without_vat',
|
||||||
|
'amount_q1',
|
||||||
|
'amount_q2',
|
||||||
|
'amount_q3',
|
||||||
|
'amount_q4',
|
||||||
|
'amount_year',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getLastSegment = (accessorKey) => {
|
||||||
|
if (typeof accessorKey !== 'string' || accessorKey === '') return '';
|
||||||
|
return accessorKey.split('.').at(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Денежная единица колонки в базе: 'thousand' | 'rub' | null (колонка не денежная).
|
||||||
|
* Порядок проверки:
|
||||||
|
* 1. явное поле columnDef.moneyUnit, если задано (в т.ч. null);
|
||||||
|
* 2. null для TEXT_COLUMN_IDS и для NON_MONEY_LAST_SEGMENTS;
|
||||||
|
* 3. 'rub' для нормативов листа AHR_LIMIT;
|
||||||
|
* 4. иначе 'thousand'.
|
||||||
|
*
|
||||||
|
* @param {{ accessorKey?: string, moneyUnit?: 'thousand'|'rub'|null }} columnDef
|
||||||
|
* @param {string} [sheetName]
|
||||||
|
* @returns {'thousand'|'rub'|null}
|
||||||
|
*/
|
||||||
|
export const getMoneyUnit = (columnDef, sheetName) => {
|
||||||
|
if (columnDef && Object.hasOwn(columnDef, 'moneyUnit')) {
|
||||||
|
return columnDef.moneyUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessorKey = columnDef?.accessorKey;
|
||||||
|
if (TEXT_COLUMN_IDS.has(accessorKey)) return null;
|
||||||
|
|
||||||
|
const lastSegment = getLastSegment(accessorKey);
|
||||||
|
if (lastSegment.endsWith('_qty') || NON_MONEY_LAST_SEGMENTS.has(lastSegment)) return null;
|
||||||
|
|
||||||
|
if (sheetName === AHR_LIMIT_SHEET && AHR_LIMIT_RUB_LAST_SEGMENTS.has(lastSegment)) return 'rub';
|
||||||
|
|
||||||
|
return 'thousand';
|
||||||
|
};
|
||||||
72
web/src/components/RealtimeTable/utils/moneyUtils.test.js
Normal file
72
web/src/components/RealtimeTable/utils/moneyUtils.test.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
AHR_LIMIT_SHEET,
|
||||||
|
DEFAULT_DENOMINATION,
|
||||||
|
TEXT_COLUMN_IDS,
|
||||||
|
UNIT_IN_RUB,
|
||||||
|
getDisplayFactor,
|
||||||
|
getMoneyUnit,
|
||||||
|
resolveDenomination,
|
||||||
|
} from './moneyUtils.js';
|
||||||
|
|
||||||
|
test('getDisplayFactor converts between units with thousand as the base', () => {
|
||||||
|
assert.equal(getDisplayFactor('thousand', 'thousand'), 1);
|
||||||
|
assert.equal(getDisplayFactor('thousand', 'million'), 0.001);
|
||||||
|
assert.equal(getDisplayFactor('thousand', 'rub'), 1000);
|
||||||
|
assert.equal(getDisplayFactor('rub', 'thousand'), 0.001);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMoneyUnit defaults ordinary money columns to thousand', () => {
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.plan.q1' }, 'AHR'), 'thousand');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMoneyUnit returns null for quantities, rates, codes and ids regardless of sheet', () => {
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.qty_q1' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.contract.vat_rate' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.contract_detail.exchange_rate' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.rented_area' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.header.section_code' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.header.internal_order' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.header.num_group' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMoneyUnit returns null for other *_qty columns via the suffix pattern', () => {
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.some_qty' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.header.amount_foreign' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMoneyUnit returns null for TEXT_COLUMN_IDS not covered by the generic suffix list', () => {
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.article_id' }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.code_razdela' }), null);
|
||||||
|
assert.ok(TEXT_COLUMN_IDS.has('data.article_id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMoneyUnit treats AHR_LIMIT limits and amounts as rub, not thousand', () => {
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.amount_q1' }, 'AHR_LIMIT'), 'rub');
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.limit_without_vat' }, 'AHR_LIMIT'), 'rub');
|
||||||
|
// тот же accessorKey на другом листе — обычная денежная колонка в тыс. руб.
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.amount_q1' }, 'OPER'), 'thousand');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMoneyUnit respects an explicit moneyUnit override, including null', () => {
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.plan.q1', moneyUnit: null }), null);
|
||||||
|
assert.equal(getMoneyUnit({ accessorKey: 'data.qty_q1', moneyUnit: 'rub' }), 'rub');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDenomination falls back to thousand when nothing is stored', () => {
|
||||||
|
assert.equal(resolveDenomination(null), DEFAULT_DENOMINATION);
|
||||||
|
assert.equal(resolveDenomination(undefined), 'thousand');
|
||||||
|
assert.equal(resolveDenomination(''), 'thousand');
|
||||||
|
assert.equal(resolveDenomination('rub'), 'rub');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UNIT_IN_RUB defines the four denominations the selector supports', () => {
|
||||||
|
assert.deepEqual(Object.keys(UNIT_IN_RUB).sort(), ['billion', 'million', 'rub', 'thousand']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AHR_LIMIT_SHEET names the sheet without a denomination selector', () => {
|
||||||
|
// Используется tableColumns.jsx/SettingPanel.jsx, чтобы отключить масштабирование и редактирование
|
||||||
|
// денежных колонок на листе, где значения всегда показываются как есть.
|
||||||
|
assert.equal(AHR_LIMIT_SHEET, 'AHR_LIMIT');
|
||||||
|
});
|
||||||
@ -5,8 +5,8 @@ const DenominationSelect = ({ value, onChange, minWidth = 140 }) => (
|
|||||||
<Select value={value} onChange={(event) => onChange(event.target.value)} aria-label='Разрядность значений'>
|
<Select value={value} onChange={(event) => onChange(event.target.value)} aria-label='Разрядность значений'>
|
||||||
<MenuItem value='rub'>в руб.</MenuItem>
|
<MenuItem value='rub'>в руб.</MenuItem>
|
||||||
<MenuItem value='thousand'>в тыс. руб.</MenuItem>
|
<MenuItem value='thousand'>в тыс. руб.</MenuItem>
|
||||||
<MenuItem value='million'>в млн. руб.</MenuItem>
|
<MenuItem value='million'>в млн руб.</MenuItem>
|
||||||
<MenuItem value='billion'>в млрд. руб.</MenuItem>
|
<MenuItem value='billion'>в млрд руб.</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { resolveDenomination } from '../RealtimeTable/utils/moneyUtils';
|
||||||
|
|
||||||
const CHANGE_EVENT = 'denomination-change';
|
const CHANGE_EVENT = 'denomination-change';
|
||||||
const readValue = (key) => {
|
|
||||||
try { return localStorage.getItem(key) || 'rub'; } catch { return 'rub'; }
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDenominationDivisor = (denomination) => ({
|
const readValue = (key) => {
|
||||||
rub: 1,
|
try {
|
||||||
thousand: 1000,
|
return resolveDenomination(localStorage.getItem(key));
|
||||||
million: 1000000,
|
} catch {
|
||||||
billion: 1000000000,
|
return resolveDenomination(null);
|
||||||
}[denomination] || 1);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function useDenomination(storageKey) {
|
export default function useDenomination(storageKey) {
|
||||||
const [values, setValues] = useState({});
|
const [values, setValues] = useState({});
|
||||||
@ -31,5 +30,5 @@ export default function useDenomination(storageKey) {
|
|||||||
return () => window.removeEventListener(CHANGE_EVENT, sync);
|
return () => window.removeEventListener(CHANGE_EVENT, sync);
|
||||||
}, [storageKey]);
|
}, [storageKey]);
|
||||||
|
|
||||||
return { denomination, setDenomination, divisor: getDenominationDivisor(denomination) };
|
return { denomination, setDenomination };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ const valueColumn = (id, header, getValue) => ({
|
|||||||
size: 112,
|
size: 112,
|
||||||
muiTableBodyCellProps: { align: 'right' },
|
muiTableBodyCellProps: { align: 'right' },
|
||||||
muiTableHeadCellProps: { align: 'right' },
|
muiTableHeadCellProps: { align: 'right' },
|
||||||
|
meta: { money: true },
|
||||||
});
|
});
|
||||||
const headerColumns = [
|
const headerColumns = [
|
||||||
{ id: 'section_code', header: '№ п/п', accessorFn: (row) => row.data?.header?.section_code, size: 92 },
|
{ id: 'section_code', header: '№ п/п', accessorFn: (row) => row.data?.header?.section_code, size: 92 },
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
|||||||
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
|
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
|
||||||
import DenominationSelect from '../../components/common/DenominationSelect';
|
import DenominationSelect from '../../components/common/DenominationSelect';
|
||||||
import useDenomination from '../../components/common/useDenomination';
|
import useDenomination from '../../components/common/useDenomination';
|
||||||
|
import { getDisplayFactor } from '../../components/RealtimeTable/utils/moneyUtils';
|
||||||
import { createFixedRowVirtualizerOptions, getRootFontSizePx } from '../../components/RealtimeTable/utils/virtualizerOptions';
|
import { createFixedRowVirtualizerOptions, getRootFontSizePx } from '../../components/RealtimeTable/utils/virtualizerOptions';
|
||||||
import { useSvod } from '../SvodPage/SvodContext';
|
import { useSvod } from '../SvodPage/SvodContext';
|
||||||
import { SvodFilters } from '../SvodPage/SvodFilters';
|
import { SvodFilters } from '../SvodPage/SvodFilters';
|
||||||
@ -36,11 +37,11 @@ const COLUMN_BAND_COLORS = {
|
|||||||
|
|
||||||
const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data);
|
const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data);
|
||||||
|
|
||||||
const formatValue = (value, divisor = 1) => {
|
const formatValue = (value, factor = 1) => {
|
||||||
if (value === null || value === undefined || value === '') return '—';
|
if (value === null || value === undefined || value === '') return '—';
|
||||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||||
if (numericValue === 0) return '-';
|
if (numericValue === 0) return '-';
|
||||||
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue / divisor);
|
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue * factor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const textColumn = (id, header, path, size = 10, options = {}) => ({
|
const textColumn = (id, header, path, size = 10, options = {}) => ({
|
||||||
@ -62,6 +63,7 @@ const numericColumn = (id, header, path, size = 7.5) => ({
|
|||||||
enableColumnFilter: false,
|
enableColumnFilter: false,
|
||||||
muiTableBodyCellProps: { align: 'right' },
|
muiTableBodyCellProps: { align: 'right' },
|
||||||
muiTableHeadCellProps: { align: 'right' },
|
muiTableHeadCellProps: { align: 'right' },
|
||||||
|
meta: { money: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const MONTHS_BY_QUARTER = {
|
const MONTHS_BY_QUARTER = {
|
||||||
@ -346,11 +348,11 @@ const getColumnConfig = (sheet) => {
|
|||||||
return COLUMN_CONFIG_CACHE.get(sheet);
|
return COLUMN_CONFIG_CACHE.get(sheet);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scaleNumericColumns = (columns, divisor) => columns.map((column) => {
|
const scaleNumericColumns = (columns, factor) => columns.map((column) => {
|
||||||
const isText = ['name', 'org_name', 'smeta_type', 'smeta_direction', 'section_code', 'contract_number'].includes(column.id);
|
const children = column.columns ? scaleNumericColumns(column.columns, factor) : undefined;
|
||||||
const children = column.columns ? scaleNumericColumns(column.columns, divisor) : undefined;
|
if (children) return { ...column, columns: children };
|
||||||
if (isText || children) return { ...column, ...(children ? { columns: children } : {}) };
|
if (!column.meta?.money) return column;
|
||||||
return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), divisor) };
|
return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), factor) };
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchesSearch = (row, query) => {
|
const matchesSearch = (row, query) => {
|
||||||
@ -370,8 +372,9 @@ export default function Svod2Page() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
||||||
const [sheet, setSheet] = useState('GO');
|
const [sheet, setSheet] = useState('GO');
|
||||||
const denominationStorageKey = `svod2_${sheet}_denomination`;
|
const denominationStorageKey = `svod2_${sheet}_denomination_v2`;
|
||||||
const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey);
|
const { denomination, setDenomination: handleDenominationChange } = useDenomination(denominationStorageKey);
|
||||||
|
const factor = useMemo(() => getDisplayFactor('thousand', denomination), [denomination]);
|
||||||
const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState);
|
const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState);
|
||||||
const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []])));
|
const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []])));
|
||||||
const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0])));
|
const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0])));
|
||||||
@ -463,7 +466,7 @@ export default function Svod2Page() {
|
|||||||
return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows;
|
return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows;
|
||||||
}, [hasCurrentData, rows, search]);
|
}, [hasCurrentData, rows, search]);
|
||||||
const { columns: rawColumns, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]);
|
const { columns: rawColumns, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]);
|
||||||
const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]);
|
const columns = useMemo(() => scaleNumericColumns(rawColumns, factor), [rawColumns, factor]);
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
columns,
|
columns,
|
||||||
data: filteredRows,
|
data: filteredRows,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import DenominationSelect from '../../components/common/DenominationSelect';
|
|||||||
import useDenomination from '../../components/common/useDenomination';
|
import useDenomination from '../../components/common/useDenomination';
|
||||||
import { blueColumn, greenColumn, orangeColumn } from '../../components/RealtimeTable/constants/columnColors';
|
import { blueColumn, greenColumn, orangeColumn } from '../../components/RealtimeTable/constants/columnColors';
|
||||||
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
|
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
|
||||||
|
import { getDisplayFactor } from '../../components/RealtimeTable/utils/moneyUtils';
|
||||||
import { buildRowHierarchy } from '../../components/RealtimeTable/utils/rowUtils';
|
import { buildRowHierarchy } from '../../components/RealtimeTable/utils/rowUtils';
|
||||||
import { createFixedRowVirtualizerOptions, getRootFontSizePx } from '../../components/RealtimeTable/utils/virtualizerOptions';
|
import { createFixedRowVirtualizerOptions, getRootFontSizePx } from '../../components/RealtimeTable/utils/virtualizerOptions';
|
||||||
|
|
||||||
@ -60,31 +61,32 @@ export const filterSummaryTree = (tree, query) =>
|
|||||||
|
|
||||||
export const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0);
|
export const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0);
|
||||||
|
|
||||||
const denominationKey = (tableKey) => `summary_${tableKey}_denomination`;
|
const denominationKey = (tableKey) => `summary_${tableKey}_denomination_v2`;
|
||||||
|
|
||||||
const scaleColumns = (columns, divisor) => columns.map((column) => {
|
const scaleColumns = (columns, factor) => columns.map((column) => {
|
||||||
const children = column.columns ? scaleColumns(column.columns, divisor) : undefined;
|
const children = column.columns ? scaleColumns(column.columns, factor) : undefined;
|
||||||
const originalCell = column.Cell;
|
const originalCell = column.Cell;
|
||||||
const isAmount = !['section_code', 'name', 'mrt-row-expand'].includes(column.id) && /plan|approved|fact|corrected|detail|total|quarter|residual|actual|adj|base|correction/i.test(column.id || '');
|
const isMoney = Boolean(column.meta?.money);
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
...(children ? { columns: children } : {}),
|
...(children ? { columns: children } : {}),
|
||||||
...(isAmount ? { Cell: ({ cell }) => {
|
...(isMoney ? { Cell: ({ cell }) => {
|
||||||
const value = cell.getValue();
|
const value = cell.getValue();
|
||||||
if (value === null || value === undefined || value === '') return originalCell ? originalCell({ cell }) : '—';
|
if (value === null || value === undefined || value === '') return originalCell ? originalCell({ cell }) : '—';
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (!Number.isFinite(numeric)) return originalCell ? originalCell({ cell }) : value;
|
if (!Number.isFinite(numeric)) return originalCell ? originalCell({ cell }) : value;
|
||||||
if (numeric === 0) return '-';
|
if (numeric === 0) return '-';
|
||||||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(numeric / divisor);
|
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(numeric * factor);
|
||||||
} } : {}),
|
} } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SummaryTable = memo(function SummaryTable({ columns, data, loading, colorBySource = false, tableKey = 'default' }) {
|
export const SummaryTable = memo(function SummaryTable({ columns, data, loading, colorBySource = false, tableKey = 'default' }) {
|
||||||
const storageKey = denominationKey(tableKey);
|
const storageKey = denominationKey(tableKey);
|
||||||
const { divisor } = useDenomination(storageKey);
|
const { denomination } = useDenomination(storageKey);
|
||||||
|
const factor = getDisplayFactor('thousand', denomination);
|
||||||
|
|
||||||
const scaledColumns = useMemo(() => scaleColumns(columns, divisor), [columns, divisor]);
|
const scaledColumns = useMemo(() => scaleColumns(columns, factor), [columns, factor]);
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
columns: scaledColumns,
|
columns: scaledColumns,
|
||||||
data,
|
data,
|
||||||
|
|||||||
@ -75,6 +75,7 @@ const valueColumn = (metric, direction, period) => ({
|
|||||||
size: tableSize(period.key === 'year' ? 8 : 6.75),
|
size: tableSize(period.key === 'year' ? 8 : 6.75),
|
||||||
muiTableBodyCellProps: { align: 'right' },
|
muiTableBodyCellProps: { align: 'right' },
|
||||||
muiTableHeadCellProps: { align: 'right' },
|
muiTableHeadCellProps: { align: 'right' },
|
||||||
|
meta: { money: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const createForm3MetricColumns = (metric) => ({
|
const createForm3MetricColumns = (metric) => ({
|
||||||
@ -137,6 +138,7 @@ const createColumns = (sheet) => {
|
|||||||
size: tableSize(8.25),
|
size: tableSize(8.25),
|
||||||
muiTableBodyCellProps: { align: 'right' },
|
muiTableBodyCellProps: { align: 'right' },
|
||||||
muiTableHeadCellProps: { align: 'right' },
|
muiTableHeadCellProps: { align: 'right' },
|
||||||
|
meta: { money: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user