feat(formulas): утилиты карты формул и API чтения формул (AURORA-1896)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-11 19:47:52 +04:00
parent af687a7e22
commit 6ba677cd39
4 changed files with 191 additions and 0 deletions

View File

@ -4,6 +4,9 @@ export const FormsSheetApi = {
get(formId, sheetName, params) { get(formId, sheetName, params) {
return api.get(`/form/${formId}/sheet/${sheetName}`, params).then((r) => r.data); 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 = {}) { export(formId, sheetName, params = {}) {
return api.get(`export/form/${formId}/sheet/${sheetName}`, { return api.get(`export/form/${formId}/sheet/${sheetName}`, {
params, params,

View File

@ -35,6 +35,9 @@ export const ProjectsApi = {
getTableData: (id, sheetName, year) => { getTableData: (id, sheetName, year) => {
return api.get(`/projects/${id}/report/${year}/${sheetName}`).then((r) => r.data); 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) => { getSummary: (id) => {
return api.get(`/projects/${id}/summary`).then((r) => r.data); return api.get(`/projects/${id}/summary`).then((r) => r.data);
}, },

View 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;
}
};

View 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);
});