feat(formulas): утилиты карты формул и API чтения формул (AURORA-1896)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af687a7e22
commit
6ba677cd39
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
75
web/src/components/RealtimeTable/utils/cellFormulaUtils.js
Normal file
75
web/src/components/RealtimeTable/utils/cellFormulaUtils.js
Normal file
@ -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;
|
||||
}
|
||||
};
|
||||
110
web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
Normal file
110
web/src/components/RealtimeTable/utils/cellFormulaUtils.test.js
Normal file
@ -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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user