From 44e6afc774f63e9d872001dd70a70612898e27e1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 15:34:08 +0400 Subject: [PATCH] =?UTF-8?q?fix(table):=20=D1=80=D0=B0=D0=B7=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BD=D0=B5=20=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=B6=D0=BD=D1=8B=D0=B5=20=D0=B8=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B8=20(AURORA-1899)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возврат тестирования 11.11: - tableColumns считал множитель по MRT column, у которого нет accessorKey, — getMoneyUnit видел пустой ключ и считал денежной любую колонку (НДС, курс, сумма в валюте, площадь масштабировались). getMoneyUnit теперь берёт column.columnDef ?? column. - текстовые колонки (комментарии, обоснования, реквизиты договоров, коды) попадали в правило «иначе thousand»: «123» в комментарии делилось, а при «млн»/«млрд» редактор обоснования закрывался как денежный. Добавлен isTextColumn: такие колонки не масштабируются и не форматируются как числа (внутренний заказ 10123 и комментарий «123» показываются как есть). Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 4 +- .../RealtimeTable/utils/moneyUtils.js | 87 ++++++++++++++++-- .../RealtimeTable/utils/moneyUtils.test.js | 89 +++++++++++++++++++ 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 39f37f7..bd07a07 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -3,7 +3,7 @@ import React, { useMemo, useCallback, useState } from 'react'; import { useCellLock } from '../../contexts/RealtimeContext'; import { resolveCellTextColor } from '../../utils/cellTextColor'; import { formatNumber } from '../../utils/numberFormat'; -import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils'; +import { isTextColumn } from '../../utils/moneyUtils'; const BASE_CELL_STYLES = { width: '100%', @@ -164,7 +164,7 @@ const CellComponent = ({ const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => { const value = cell.getValue(); - 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)) && !isTextColumn(column); let display = String(value || ''); let negative = false; if (isNum) { diff --git a/web/src/components/RealtimeTable/utils/moneyUtils.js b/web/src/components/RealtimeTable/utils/moneyUtils.js index 22c6b8f..864d142 100644 --- a/web/src/components/RealtimeTable/utils/moneyUtils.js +++ b/web/src/components/RealtimeTable/utils/moneyUtils.js @@ -41,9 +41,9 @@ export const TEXT_COLUMN_IDS = new Set([ 'data.expense_item_code', ]); -// Последний сегмент accessorKey (после последней точки), который никогда не денежный: -// количества, ставки, курсы, коды, идентификаторы и т.п. -const NON_MONEY_LAST_SEGMENTS = new Set([ +// Последний сегмент accessorKey (после последней точки) числовой, но не денежной колонки: +// количества, площади, ставки, курсы, суммы в валюте. Форматируются как числа, не масштабируются. +const NUMERIC_NON_MONEY_LAST_SEGMENTS = new Set([ 'qty_q1', 'qty_q2', 'qty_q3', @@ -52,6 +52,13 @@ const NON_MONEY_LAST_SEGMENTS = new Set([ 'vat_rate', 'exchange_rate', 'amount_foreign', +]); +const NUMERIC_NON_MONEY_SUFFIXES = ['_qty']; + +// Последний сегмент accessorKey текстовой колонки: коды, идентификаторы и свободный текст. +// Не масштабируются и не форматируются как числа, даже если значение похоже на число +// (внутренний заказ 10123, комментарий «123»). +const TEXT_LAST_SEGMENTS = new Set([ 'section', 'section_code', 'section_no', @@ -65,8 +72,70 @@ const NON_MONEY_LAST_SEGMENTS = new Set([ 'pay_year', 'order', 'expense_item_code', + 'id_stati', + // Свободный текст (комментарии, обоснования, реквизиты договоров, даты, флаги). + // Без них значение «123» в комментарии делилось на разрядность, а при разрядности + // не «тыс.» текстовый редактор закрывался как денежный (возврат AURORA-1899, 11.11). + // Комментарии с суффиксом *_comment ловит TEXT_SUFFIXES. + 'comment', + 'justification', + 'note', + 'name', + 'counterparty', + 'subject', + 'reference', + 'contract_ref', + 'deadline', + 'delivery_deadline', + 'date', + 'pay_date', + 'contract_end_date', + 'currency', + 'act', + 'pay_act', + 'scheme', + 'payment_scheme', + 'rf_schedule', + 'addenda', + 'proc_plan', + 'proc_method', + 'procurement_plan', + 'procurement_method', + 'property', + 'allocation_purpose', + 'protocol', + 'protocol_reference', + 'approved', + 'centralized_flag', + 'object_type', + 'unit', + 'address', + 'equipment_name', ]); +const TEXT_SUFFIXES = ['_comment']; + +const hasSuffix = (segment, suffixes) => suffixes.some((suffix) => segment.endsWith(suffix)); + +// И конфиг колонки, и MRT column: у последнего accessorKey и moneyUnit лежат в column.columnDef +// (возврат AURORA-1899, 11.11: без этого любая колонка считалась денежной и НДС/курс/площадь +// масштабировались). +const toColumnDef = (column) => column?.columnDef ?? column; + +/** + * Текстовая колонка: значение показывается как есть, без числового форматирования + * и без масштабирования разрядностью. + * + * @param {{ accessorKey?: string, columnDef?: { accessorKey?: string } }} column + * @returns {boolean} + */ +export const isTextColumn = (column) => { + const accessorKey = toColumnDef(column)?.accessorKey; + if (TEXT_COLUMN_IDS.has(accessorKey)) return true; + const lastSegment = getLastSegment(accessorKey); + return TEXT_LAST_SEGMENTS.has(lastSegment) || hasSuffix(lastSegment, TEXT_SUFFIXES); +}; + // Лист "АХР лимиты" (FORM_2/AHR_LIMIT) — нормативы хранятся в рублях, а не в тыс. руб. // У листа нет селектора разрядности (SettingPanel), значения показываются как есть. export const AHR_LIMIT_SHEET = 'AHR_LIMIT'; @@ -97,16 +166,18 @@ const getLastSegment = (accessorKey) => { * @param {string} [sheetName] * @returns {'thousand'|'rub'|null} */ -export const getMoneyUnit = (columnDef, sheetName) => { +export const getMoneyUnit = (column, sheetName) => { + const columnDef = toColumnDef(column); if (columnDef && Object.hasOwn(columnDef, 'moneyUnit')) { return columnDef.moneyUnit; } - const accessorKey = columnDef?.accessorKey; - if (TEXT_COLUMN_IDS.has(accessorKey)) return null; + if (isTextColumn(columnDef)) return null; - const lastSegment = getLastSegment(accessorKey); - if (lastSegment.endsWith('_qty') || NON_MONEY_LAST_SEGMENTS.has(lastSegment)) return null; + const lastSegment = getLastSegment(columnDef?.accessorKey); + if (NUMERIC_NON_MONEY_LAST_SEGMENTS.has(lastSegment) || hasSuffix(lastSegment, NUMERIC_NON_MONEY_SUFFIXES)) { + return null; + } if (sheetName === AHR_LIMIT_SHEET && AHR_LIMIT_RUB_LAST_SEGMENTS.has(lastSegment)) return 'rub'; diff --git a/web/src/components/RealtimeTable/utils/moneyUtils.test.js b/web/src/components/RealtimeTable/utils/moneyUtils.test.js index 169e51f..7e27c95 100644 --- a/web/src/components/RealtimeTable/utils/moneyUtils.test.js +++ b/web/src/components/RealtimeTable/utils/moneyUtils.test.js @@ -7,6 +7,7 @@ import { UNIT_IN_RUB, getDisplayFactor, getMoneyUnit, + isTextColumn, resolveDenomination, } from './moneyUtils.js'; @@ -54,6 +55,94 @@ test('getMoneyUnit respects an explicit moneyUnit override, including null', () assert.equal(getMoneyUnit({ accessorKey: 'data.qty_q1', moneyUnit: 'rub' }), 'rub'); }); +// AURORA-1899, возврат тестирования 11.11: tableColumns передаёт в расчёт множителя +// MRT column (column.id + column.columnDef), у которого нет своего accessorKey. +test('getMoneyUnit reads accessorKey from an MRT column object via columnDef', () => { + const mrtColumn = (accessorKey) => ({ id: accessorKey, columnDef: { accessorKey } }); + assert.equal(getMoneyUnit(mrtColumn('data.contract_detail.vat_rate'), 'OPER'), null); + assert.equal(getMoneyUnit(mrtColumn('data.contract_detail.exchange_rate'), 'OPER'), null); + assert.equal(getMoneyUnit(mrtColumn('data.contract_detail.amount_foreign'), 'OPER'), null); + assert.equal(getMoneyUnit(mrtColumn('data.header.internal_order'), 'OPER'), null); + assert.equal(getMoneyUnit(mrtColumn('data.rented_area'), 'AHR_RENT'), null); + assert.equal(getMoneyUnit(mrtColumn('data.plan.q1'), 'OPER'), 'thousand'); + assert.equal(getMoneyUnit(mrtColumn('data.amount_q1'), 'AHR_LIMIT'), 'rub'); + assert.equal(getMoneyUnit({ id: 'data.plan.q1', columnDef: { accessorKey: 'data.plan.q1', moneyUnit: null } }), null); +}); + +test('getMoneyUnit returns null for text columns: comments, justifications and other free text', () => { + for (const accessorKey of [ + 'data.plan.comment', + 'data.q2.adj_comment', + 'data.q2.base_correction_comment', + 'data.q1.transfer_next_comment', + 'data.q1.transfer_far_comment', + 'data.seq_dfip.justification', + 'data.header.justification', + 'data.collegial.note', + 'data.header.name', + 'data.contract.counterparty', + 'data.contract.subject', + 'data.contract.reference', + 'data.contract.deadline', + 'data.contract.date', + 'data.contract.currency', + 'data.contract.act', + 'data.contract.scheme', + 'data.contract_detail.payment_scheme', + 'data.contract_detail.rf_schedule', + 'data.contract_detail.addenda', + 'data.ckk.proc_plan', + 'data.ckk.proc_method', + 'data.allocation.property', + 'data.collegial.protocol', + 'data.collegial.approved', + 'data.contract_summary.centralized_flag', + 'data.contract_end_date', + 'data.object_type', + 'data.unit', + 'data.address', + ]) { + assert.equal(getMoneyUnit({ accessorKey }, 'OPER'), null, accessorKey); + } +}); + +// Возврат 11.11: внутренний заказ 10123 и комментарий «123» ожидаются как исходный текст, +// а не «10 123,0» / «123,0»; ставки и количества остаются числами. +test('isTextColumn marks codes and free text as text, numeric non-money columns as numbers', () => { + const mrtColumn = (accessorKey) => ({ id: accessorKey, columnDef: { accessorKey } }); + for (const accessorKey of [ + 'data.header.internal_order', + 'data.header.section_code', + 'data.header.item_id', + 'data.plan.comment', + 'data.q2.adj_comment', + 'data.seq_dfip.justification', + 'data.contract.counterparty', + 'data.article_id', + ]) { + assert.equal(isTextColumn({ accessorKey }), true, accessorKey); + assert.equal(isTextColumn(mrtColumn(accessorKey)), true, `mrt ${accessorKey}`); + } + for (const accessorKey of [ + 'data.contract_detail.vat_rate', + 'data.contract_detail.exchange_rate', + 'data.contract_detail.amount_foreign', + 'data.rented_area', + 'data.qty_q1', + 'data.acquired_qty', + 'data.plan.q1', + ]) { + assert.equal(isTextColumn({ accessorKey }), false, accessorKey); + } +}); + +test('getMoneyUnit keeps money columns whose names resemble text ones as thousand', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.collegial.amount' }, 'OPER'), 'thousand'); + assert.equal(getMoneyUnit({ accessorKey: 'data.q1.transfer_q2_delay_acts' }, 'OPER'), 'thousand'); + assert.equal(getMoneyUnit({ accessorKey: 'data.approved.q1' }, 'OPER'), 'thousand'); + assert.equal(getMoneyUnit({ accessorKey: 'data.q1.pay_amount' }, 'OPER'), 'thousand'); +}); + test('resolveDenomination falls back to thousand when nothing is stored', () => { assert.equal(resolveDenomination(null), DEFAULT_DENOMINATION); assert.equal(resolveDenomination(undefined), 'thousand'); -- 2.47.2