diff --git a/web/src/api/form_sheet.js b/web/src/api/form_sheet.js index 7ac4ecd..c048a29 100644 --- a/web/src/api/form_sheet.js +++ b/web/src/api/form_sheet.js @@ -4,6 +4,9 @@ export const FormsSheetApi = { get(formId, sheetName, params) { return api.get(`/form/${formId}/sheet/${sheetName}`, params).then((r) => r.data); }, + getFormulas(formId, sheetName) { + return api.get(`/form/${formId}/sheet/${encodeURIComponent(sheetName)}/formulas`).then((r) => r.data.result); + }, export(formId, sheetName, params = {}) { return api.get(`export/form/${formId}/sheet/${sheetName}`, { params, diff --git a/web/src/api/projects.js b/web/src/api/projects.js index e7c8ca8..cbbdc2f 100644 --- a/web/src/api/projects.js +++ b/web/src/api/projects.js @@ -35,6 +35,9 @@ export const ProjectsApi = { getTableData: (id, sheetName, year) => { return api.get(`/projects/${id}/report/${year}/${sheetName}`).then((r) => r.data); }, + getReportFormulas: (id, year, reportType) => { + return api.get(`/projects/${id}/report/${year}/${reportType}/formulas`).then((r) => r.data.result); + }, getSummary: (id) => { return api.get(`/projects/${id}/summary`).then((r) => r.data); }, diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js new file mode 100644 index 0000000..0964c8d --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.js @@ -0,0 +1,75 @@ +// AURORA-1896: чистые утилиты карты формул ячеек (без React/MUI/constants.jsx). + +export const formulaKey = (lineId, column) => `${lineId}|${column}`; + +export const buildFormulaMap = (list) => { + const map = new Map(); + for (const item of list ?? []) { + map.set(formulaKey(item.line_id, item.column), item); + } + return map; +}; + +// Возвращает новый Map; вход не мутируется. Фоновые события без ячейки +// (синхронизация сателлитов: data:{source_sheet}) карту не меняют. +export const applyFormulaEvent = (map, data) => { + if (!data || data.line_id == null || data.column == null) return map; + const next = new Map(map); + const key = formulaKey(data.line_id, data.column); + if (typeof data.formula === 'string' && data.formula) { + next.set(key, { + line_id: data.line_id, + column: data.column, + expression: data.formula, + value: Number(data.value), + updated_by: data.formula_meta?.updated_by, + updated_at: data.formula_meta?.updated_at, + }); + } else { + next.delete(key); + } + return next; +}; + +const toFiniteNumber = (v) => { + if (v == null || (typeof v === 'string' && v.trim() === '')) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +}; + +export const isFormulaActual = (formula, value) => { + if (!formula) return false; + const a = toFiniteNumber(formula.value); + const b = toFiniteNumber(value); + if (a === null || b === null) return false; + return Math.abs(a - b) <= 1e-9; +}; + +const pad = (n) => String(n).padStart(2, '0'); + +export const formatFormulaHint = (formula) => { + const author = formula.updated_by; + const name = author?.full_name || `Пользователь #${author?.id}`; + const parts = [`=${formula.expression}`, name]; + if (formula.updated_at) { + const d = new Date(formula.updated_at); + if (!Number.isNaN(d.getTime())) { + parts.push(`${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`); + } + } + return parts.join(' · '); +}; + +export const clearLegacyFormulaStorage = (storage) => { + try { + const toRemove = []; + for (let i = 0; i < storage.length; i++) { + const k = storage.key(i); + if (typeof k === 'string' && k.startsWith('formula_')) toRemove.push(k); + } + for (const k of toRemove) storage.removeItem(k); + return toRemove.length; + } catch { + return 0; + } +}; diff --git a/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js new file mode 100644 index 0000000..0320013 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyFormulaEvent, + buildFormulaMap, + clearLegacyFormulaStorage, + formatFormulaHint, + formulaKey, + isFormulaActual, +} from './cellFormulaUtils.js'; + +const user = { id: 7, full_name: 'Иванов И.И.' }; + +test('formulaKey склеивает line_id и column', () => { + assert.equal(formulaKey(5, 'q1'), '5|q1'); +}); + +test('buildFormulaMap строит карту по ключу', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1+2', value: 3, updated_by: user, updated_at: 'x' }]); + assert.equal(map.size, 1); + assert.equal(map.get('1|a').expression, '1+2'); + assert.equal(buildFormulaMap(null).size, 0); +}); + +test('applyFormulaEvent: строка записывает формулу', () => { + const map = new Map(); + const next = applyFormulaEvent(map, { + line_id: 1, + column: 'a', + value: '150', + formula: '100+50', + formula_meta: { updated_by: user, updated_at: '2026-10-27T14:03:00' }, + }); + assert.notEqual(next, map); + assert.equal(map.size, 0); + assert.deepEqual(next.get('1|a'), { + line_id: 1, + column: 'a', + expression: '100+50', + value: 150, + updated_by: user, + updated_at: '2026-10-27T14:03:00', + }); +}); + +test('applyFormulaEvent: formula null удаляет, вход не мутируется', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1', value: 1 }]); + const next = applyFormulaEvent(map, { line_id: 1, column: 'a', value: 2, formula: null }); + assert.equal(next.size, 0); + assert.equal(map.size, 1); +}); + +test('applyFormulaEvent: без поля formula удаляет', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1', value: 1 }]); + const next = applyFormulaEvent(map, { line_id: 1, column: 'a', value: 2 }); + assert.equal(next.size, 0); +}); + +test('applyFormulaEvent: фоновое событие без line_id/column возвращает ту же карту', () => { + const map = buildFormulaMap([{ line_id: 1, column: 'a', expression: '1', value: 1 }]); + assert.equal(applyFormulaEvent(map, { source_sheet: 'AHR_RENT' }), map); + assert.equal(applyFormulaEvent(map, { line_id: 1, source_sheet: 'X' }), map); + assert.equal(map.size, 1); +}); + +test('isFormulaActual', () => { + const f = { expression: '100+50', value: 150 }; + assert.equal(isFormulaActual(f, 150), true); + assert.equal(isFormulaActual(f, '150'), true); + assert.equal(isFormulaActual({ ...f, value: '150' }, 150.0000000001), true); + assert.equal(isFormulaActual({ ...f, value: 150 }, 151), false); + assert.equal(isFormulaActual(null, 150), false); + assert.equal(isFormulaActual({ ...f, value: 150 }, 'abc'), false); + assert.equal(isFormulaActual({ ...f, value: 150 }, null), false); + assert.equal(isFormulaActual({ ...f, value: 150 }, ''), false); +}); + +test('formatFormulaHint', () => { + assert.equal( + formatFormulaHint({ expression: '100+50', updated_by: user, updated_at: '2026-10-27T14:03:00' }), + '=100+50 · Иванов И.И. · 27.10.2026 14:03', + ); + assert.equal( + formatFormulaHint({ expression: '1+1', updated_by: { id: 7 }, updated_at: '2026-10-27T14:03:00' }), + '=1+1 · Пользователь #7 · 27.10.2026 14:03', + ); + assert.equal(formatFormulaHint({ expression: '1+1', updated_by: user }), '=1+1 · Иванов И.И.'); +}); + +test('clearLegacyFormulaStorage', () => { + const keys = ['formula_v2_a', 'formula_x', 'other']; + const storage = { + get length() { + return keys.length; + }, + key: (i) => keys[i] ?? null, + removeItem: (k) => { + keys.splice(keys.indexOf(k), 1); + }, + }; + assert.equal(clearLegacyFormulaStorage(storage), 2); + assert.deepEqual(keys, ['other']); + const broken = { + get length() { + throw new Error('denied'); + }, + }; + assert.equal(clearLegacyFormulaStorage(broken), 0); + assert.equal(clearLegacyFormulaStorage(null), 0); +});