/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/MtoJsQueryEngine.js
724 строки
22 KB
Starolat Sergei
Фаза 6: полное удаление SQL.js и переход на JS Data Engine
28 июн 2026, 10:02
28 июн 2026, 10:02
ff2000f
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview MtoJsQueryEngine — JS-аналог SQL-агрегаций для MtoSummaryPanel * @module MtoJsQueryEngine * @version 1.0.0 * * Работает напрямую с in-memory индексами DataIndexer без SQL. */ import dataIndexer from "./DataIndexer.js"; import weekIndexService from "./WeekIndexService.js"; import summaryJsQueryEngine from "./SummaryJsQueryEngine.js"; /** * @typedef {import('../types.js').DatabaseIndexes} DatabaseIndexes * @typedef {import('../types.js').Activity} Activity * @typedef {import('../types.js').CodeFilterItem} CodeFilterItem * @typedef {import('../types.js').ProgressCategory} ProgressCategory * @typedef {import('../types.js').WeekRange} WeekRange */ const REPORT_CODE_TYPE_NAME = "ЦПС_Отчетность"; const REPORT_CODE_VALUE_SHORT_NAME = "S"; const CATEGORY_CODE_TYPE_NAME = "ЦПС_Вид работ для отчета"; const GP_CODE_TYPE_PREFIX = "Позиция_ГП"; const LABOR_TYPE = "labor"; /** * @param {string|Date|null|undefined} dateInput * @returns {string|null} */ function formatDateToDDMMYYYY(dateInput) { if (!dateInput) return null; let date; if (dateInput instanceof Date) { date = dateInput; } else if (typeof dateInput === "string") { if (/^\d{2}\.\d{2}\.\d{4}$/.test(dateInput)) return dateInput; const parts = dateInput.substring(0, 10).split("-"); if (parts.length === 3) { date = new Date( Date.UTC( parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), ), ); } else { date = new Date(dateInput); } } else { return null; } if (isNaN(date.getTime())) return null; const day = String(date.getUTCDate()).padStart(2, "0"); const month = String(date.getUTCMonth() + 1).padStart(2, "0"); const year = date.getUTCFullYear(); return `${day}.${month}.${year}`; } /** * @param {number} wi * @param {WeekRange|null} weekRange * @returns {boolean} */ function isWeekInRange(wi, weekRange) { if (!weekRange) return true; if (weekRange.startIdx != null && wi < weekRange.startIdx) return false; if (weekRange.endIdx != null && wi > weekRange.endIdx) return false; return true; } class MtoJsQueryEngine { /** * @param {string} databaseId * @returns {string} YYYY-MM-DD */ getProjectDataDate(databaseId) { const indexes = this._getIndexes(databaseId); for (const project of indexes.projectsById.values()) { if (project.dataDate) return project.dataDate.substring(0, 10); } return new Date().toISOString().split("T")[0]; } /** * Данные для одной категории МТО (gauge). * @param {string} databaseId * @param {{key:string, title:string, codeValues:string[], chartCategory:string}} category * @param {Object} options * @param {CodeFilterItem[]} [options.codeFilters] * @param {number[]} [options.wbsFilterIds] * @param {WeekRange} [options.weekRange] * @param {string} [options.reportDate] * @param {'mixed'|'baseline'|'current'|'absolute'} [options.normalizationMode] * @returns {Promise<ProgressCategory>} */ async getCategoryData(databaseId, category, options = {}) { const indexes = this._getIndexes(databaseId); const activities = await this._resolveCategoryActivities( databaseId, indexes, category, options, ); if (activities.size === 0) { return this._emptyCategoryData(); } const reportDate = options.reportDate || this.getProjectDataDate(databaseId); const { planValue, actualValue, deltaValue, totalPlan, totalFact } = this._computePlanFact( indexes, activities, reportDate, options.weekRange || null, options.normalizationMode || "mixed", ); // dates: across both schedules, no labor/week filter const startDate = this._minDate(activities, "current", "start"); const endDate = this._maxDate(activities, "current", "end"); const targetStartDate = this._minDate(activities, "target", "start"); const targetEndDate = this._maxDate(activities, "target", "end"); const weeklyDataTable = this._buildWeeklyTable( indexes, activities, options.weekRange || null, ); return { accumulatedPlanPrevWeek: parseFloat(planValue.toFixed(1)), accumulatedFactPrevWeek: parseFloat(actualValue.toFixed(1)), periodPlanPrevWeek: null, periodFactPrevWeek: null, delta: parseFloat(deltaValue.toFixed(1)), trend: null, totalPlan, totalFact, startDate: formatDateToDDMMYYYY(startDate) || "", endDate: formatDateToDDMMYYYY(endDate) || "", targetStartDate: formatDateToDDMMYYYY(targetStartDate) || "", targetEndDate: formatDateToDDMMYYYY(targetEndDate) || "", weeklyDataTable, }; } /** * Данные для 9 bar-chart виджетов по позициям ГП. * @param {string} databaseId * @param {Object} options * @param {Array<{key:string, title:string, codeValues:string[], chartCategory:string}>} options.categories * @param {CodeFilterItem[]} [options.codeFilters] * @param {number[]} [options.wbsFilterIds] * @param {WeekRange} [options.weekRange] * @param {string} [options.reportDate] * @param {'mixed'|'baseline'|'current'|'absolute'} [options.normalizationMode] * @returns {Promise<{title:string, categories:string[], plan:number[], fact:number[]}[]>} */ async getChartsData(databaseId, options = {}) { const indexes = this._getIndexes(databaseId); const gpTypeId = this._detectGpPositionCodeType(indexes); if (gpTypeId == null) return []; const reportTypeId = this._resolveCodeTypeIdByName( indexes, REPORT_CODE_TYPE_NAME, ); const reportValueId = reportTypeId != null ? this._resolveCodeValueIdByShortName( indexes, reportTypeId, REPORT_CODE_VALUE_SHORT_NAME, ) : null; if (reportTypeId == null || reportValueId == null) return []; const catTypeId = this._resolveCodeTypeIdByName( indexes, CATEGORY_CODE_TYPE_NAME, ); if (catTypeId == null) return []; const baseRows = await summaryJsQueryEngine.resolveActivities(databaseId, { codeFilters: [ ...(options.codeFilters || []), { codeTypeId: reportTypeId, codeValueIds: [reportValueId], enabled: true, }, ], wbsFilterIds: options.wbsFilterIds || [], scheduleType: "both", }); const currentRows = baseRows.filter((r) => { const a = indexes.activitiesById.get(r.activityId); return a?.scheduleType === "current"; }); const positions = this._loadTopPositionsFromRows( indexes, gpTypeId, currentRows, ); if (!positions.length) return []; /** @type {Map<string, number>} */ const planMap = new Map(); /** @type {Map<string, number>} */ const factMap = new Map(); for (const category of options.categories || []) { const catValueIds = this._resolveCodeValueIdsByShortNames( indexes, catTypeId, category.codeValues, ); const catRows = baseRows.filter((r) => this._activityHasOneOfCodeValues( indexes, r.activityId, catTypeId, catValueIds, ), ); for (const posDesc of positions) { const posValueId = this._resolveCodeValueIdByDescription( indexes, gpTypeId, posDesc, ); const rows = catRows.filter((r) => posValueId != null ? this._activityHasCodeValue( indexes, r.activityId, gpTypeId, posValueId, ) : false, ); const activities = new Set( rows .map((r) => indexes.activitiesById.get(r.activityId)) .filter(Boolean), ); const { planValue, actualValue } = this._computePlanFact( indexes, activities, options.reportDate || this.getProjectDataDate(databaseId), options.weekRange || null, options.normalizationMode || "mixed", ); const key = `${posDesc}|${category.chartCategory}`; planMap.set(key, Math.round(planValue || 0)); factMap.set(key, Math.round(actualValue || 0)); } } const labelOrder = (options.categories || []).map((c) => c.title); return positions.map((posDesc) => { const plan = []; const fact = []; for (const cat of options.categories || []) { const key = `${posDesc}|${cat.chartCategory}`; plan.push(planMap.get(key) || 0); fact.push(factMap.get(key) || 0); } return { title: posDesc, categories: labelOrder, plan, fact, }; }); } /** * @private * @param {string} databaseId * @returns {DatabaseIndexes} */ _getIndexes(databaseId) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } return indexes; } /** * @private * @param {string} databaseId * @param {DatabaseIndexes} indexes * @param {{codeValues:string[]}} category * @param {Object} options * @returns {Promise<Set<Activity>>} */ async _resolveCategoryActivities(databaseId, indexes, category, options) { const reportTypeId = this._resolveCodeTypeIdByName( indexes, REPORT_CODE_TYPE_NAME, ); const reportValueId = reportTypeId != null ? this._resolveCodeValueIdByShortName( indexes, reportTypeId, REPORT_CODE_VALUE_SHORT_NAME, ) : null; const catTypeId = this._resolveCodeTypeIdByName( indexes, CATEGORY_CODE_TYPE_NAME, ); const catValueIds = catTypeId != null ? this._resolveCodeValueIdsByShortNames( indexes, catTypeId, category.codeValues, ) : new Set(); /** @type {CodeFilterItem[]} */ const syntheticFilters = [...(options.codeFilters || [])]; if (reportTypeId != null && reportValueId != null) { syntheticFilters.push({ codeTypeId: reportTypeId, codeValueIds: [reportValueId], enabled: true, }); } const rows = await summaryJsQueryEngine.resolveActivities(databaseId, { codeFilters: syntheticFilters, wbsFilterIds: options.wbsFilterIds || [], scheduleType: "both", }); const result = new Set(); for (const row of rows) { if (catTypeId != null && !catValueIds.size) continue; if ( catTypeId != null && !this._activityHasOneOfCodeValues( indexes, row.activityId, catTypeId, catValueIds, ) ) { continue; } const a = indexes.activitiesById.get(row.activityId); if (a) result.add(a); } return result; } /** * @private * @param {DatabaseIndexes} indexes * @param {Set<Activity>} activities * @param {string} reportDate YYYY-MM-DD * @param {WeekRange|null} weekRange * @param {'mixed'|'baseline'|'current'|'absolute'} normalizationMode * @returns {{planValue:number, actualValue:number, deltaValue:number, plannedByDate:number, totalPlan:number, actualByDate:number, totalFact:number}} */ _computePlanFact( indexes, activities, reportDate, weekRange, normalizationMode, ) { const reportDateNum = reportDate ? new Date(`${reportDate}T00:00:00Z`).getTime() : null; let plannedByDate = 0; let totalPlan = 0; let actualByDate = 0; let totalFact = 0; for (const a of activities) { const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null) continue; const res = indexes.resourcesById.get(ra.resourceId); if (!res || res.type !== LABOR_TYPE) continue; const range = indexes.spreadIndexByAssignment.get(ra.assignmentId); if (!range) continue; for (let i = range.start; i < range.end; i++) { const wi = indexes.spread.weekIndices[i]; if (wi == null || wi <= 0) continue; if (!isWeekInRange(wi, weekRange)) continue; const info = weekIndexService.getWeekInfo(indexes.databaseId, wi); const weekStart = info?.weekStart; const weekStartNum = weekStart ? new Date(`${weekStart}T00:00:00Z`).getTime() : null; const beforeReport = reportDateNum == null || (weekStartNum != null && weekStartNum <= reportDateNum); if (a.scheduleType === "target") { const v = (indexes.spread.actualUnits[i] || 0) + (indexes.spread.remainingUnits[i] || 0); totalPlan += v; if (beforeReport) plannedByDate += v; } else if (a.scheduleType === "current") { const actual = indexes.spread.actualUnits[i] || 0; const remaining = indexes.spread.remainingUnits[i] || 0; totalFact += actual + remaining; if (beforeReport) actualByDate += actual; } } } } const normMode = normalizationMode || "mixed"; const planDenominator = normMode === "current" ? totalFact : totalPlan; const factDenominator = normMode === "baseline" ? totalPlan : totalFact; let planValue; let actualValue; let deltaValue; if (normMode === "absolute") { planValue = plannedByDate; actualValue = actualByDate; deltaValue = actualByDate - plannedByDate; } else { planValue = planDenominator > 0 ? (plannedByDate / planDenominator) * 100 : 0; actualValue = factDenominator > 0 ? (actualByDate / factDenominator) * 100 : 0; deltaValue = actualValue - planValue; } return { planValue, actualValue, deltaValue, plannedByDate, totalPlan, actualByDate, totalFact, }; } /** * @private * @param {DatabaseIndexes} indexes * @param {Set<Activity>} activities * @param {WeekRange|null} weekRange * @returns {{weekStartDate:string, weekEndDate:string, planUnits:number, actualUnits:number, remainingUnits:number}[]} */ _buildWeeklyTable(indexes, activities, weekRange) { /** @type {Map<number, {planUnits:number, actualUnits:number, remainingUnits:number}>} */ const byWeek = new Map(); for (const a of activities) { const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null) continue; const res = indexes.resourcesById.get(ra.resourceId); if (!res || res.type !== LABOR_TYPE) continue; const range = indexes.spreadIndexByAssignment.get(ra.assignmentId); if (!range) continue; for (let i = range.start; i < range.end; i++) { const wi = indexes.spread.weekIndices[i]; if (wi == null || wi <= 0) continue; if (!isWeekInRange(wi, weekRange)) continue; if (!byWeek.has(wi)) { byWeek.set(wi, { planUnits: 0, actualUnits: 0, remainingUnits: 0 }); } const entry = byWeek.get(wi); const actual = indexes.spread.actualUnits[i] || 0; const remaining = indexes.spread.remainingUnits[i] || 0; if (a.scheduleType === "target") { entry.planUnits += actual + remaining; } else if (a.scheduleType === "current") { entry.actualUnits += actual; entry.remainingUnits += remaining; } } } } const sortedWeeks = Array.from(byWeek.keys()).sort((a, b) => a - b); return sortedWeeks.map((wi) => { const info = weekIndexService.getWeekInfo(indexes.databaseId, wi); const entry = byWeek.get(wi); return { weekStartDate: info?.weekStart || String(wi), weekEndDate: info?.weekEnd || "", planUnits: entry.planUnits, actualUnits: entry.actualUnits, remainingUnits: entry.remainingUnits, }; }); } /** * @private * @param {DatabaseIndexes} indexes * @param {number} gpTypeId * @param {Array<{activityId:number}>} currentRows * @returns {string[]} */ _loadTopPositionsFromRows(indexes, gpTypeId, currentRows) { /** @type {Map<string, number>} */ const minFloatByDesc = new Map(); for (const row of currentRows) { const a = indexes.activitiesById.get(row.activityId); if (!a || a.scheduleType !== "current") continue; const gpValueId = indexes.activityCodesByType.get(a.id)?.get(gpTypeId); if (gpValueId == null) continue; const desc = indexes.codeValueById.get(gpValueId)?.description || `GP-${gpValueId}`; const current = minFloatByDesc.has(desc) ? minFloatByDesc.get(desc) : Infinity; if (a.totalFloat != null && a.totalFloat < (current || Infinity)) { minFloatByDesc.set(desc, a.totalFloat); } } return Array.from(minFloatByDesc.entries()) .sort((a, b) => a[1] - b[1]) .slice(0, 9) .map(([desc]) => desc); } /** * @private * @param {DatabaseIndexes} indexes * @returns {number|undefined} */ _detectGpPositionCodeType(indexes) { for (const type of indexes.codeTypeById.values()) { if (type.name.startsWith(GP_CODE_TYPE_PREFIX)) return type.typeId; } for (const type of indexes.codeTypeById.values()) { if (type.name.includes("Позиция")) return type.typeId; } return undefined; } /** * @private * @param {DatabaseIndexes} indexes * @param {string} name * @returns {number|undefined} */ _resolveCodeTypeIdByName(indexes, name) { for (const type of indexes.codeTypeById.values()) { if (type.name === name) return type.typeId; } return undefined; } /** * @private * @param {DatabaseIndexes} indexes * @param {number|undefined} typeId * @param {string} shortName * @returns {number|undefined} */ _resolveCodeValueIdByShortName(indexes, typeId, shortName) { if (typeId == null) return undefined; const values = indexes.codeValuesByType.get(typeId); if (!values) return undefined; for (const cv of values.values()) { if (cv.shortName === shortName) return cv.valueId; } return undefined; } /** * @private * @param {DatabaseIndexes} indexes * @param {number|undefined} typeId * @param {string[]} shortNames * @returns {Set<number>} */ _resolveCodeValueIdsByShortNames(indexes, typeId, shortNames) { const result = new Set(); if (typeId == null) return result; const values = indexes.codeValuesByType.get(typeId); if (!values) return result; for (const cv of values.values()) { if (shortNames.includes(cv.shortName)) result.add(cv.valueId); } return result; } /** * @private * @param {DatabaseIndexes} indexes * @param {number|undefined} typeId * @param {string} description * @returns {number|undefined} */ _resolveCodeValueIdByDescription(indexes, typeId, description) { if (typeId == null) return undefined; const values = indexes.codeValuesByType.get(typeId); if (!values) return undefined; for (const cv of values.values()) { if (cv.description === description) return cv.valueId; } return undefined; } /** * @private * @param {number} activityId * @param {number|undefined} typeId * @param {number|undefined} valueId * @returns {boolean} */ _activityHasCodeValue(indexes, activityId, typeId, valueId) { if (typeId == null || valueId == null) return false; return indexes.activityCodesByType.get(activityId)?.get(typeId) === valueId; } /** * @private * @param {number} activityId * @param {number|undefined} typeId * @param {Set<number>} valueIds * @returns {boolean} */ _activityHasOneOfCodeValues(indexes, activityId, typeId, valueIds) { if (typeId == null || !valueIds.size) return false; const assignedValueId = indexes.activityCodesByType .get(activityId) ?.get(typeId); return assignedValueId != null && valueIds.has(assignedValueId); } /** * @private * @param {Set<Activity>} activities * @param {'current'|'target'} scheduleType * @param {'start'|'end'} edge * @returns {string|null} */ _minDate(activities, scheduleType, edge) { let min = null; for (const a of activities) { if (a.scheduleType !== scheduleType) continue; const date = edge === "start" ? a.actualStartDate || a.startDate || null : a.actualEndDate || a.endDate || null; if (!date) continue; if (min == null || date < min) min = date; } return min; } /** * @private * @param {Set<Activity>} activities * @param {'current'|'target'} scheduleType * @param {'start'|'end'} edge * @returns {string|null} */ _maxDate(activities, scheduleType, edge) { let max = null; for (const a of activities) { if (a.scheduleType !== scheduleType) continue; const date = edge === "start" ? a.actualStartDate || a.startDate || null : a.actualEndDate || a.endDate || null; if (!date) continue; if (max == null || date > max) max = date; } return max; } /** * @private * @returns {ProgressCategory} */ _emptyCategoryData() { return { accumulatedPlanPrevWeek: 0, accumulatedFactPrevWeek: 0, periodPlanPrevWeek: null, periodFactPrevWeek: null, delta: 0, trend: null, totalPlan: 0, totalFact: 0, startDate: "", endDate: "", targetStartDate: "", targetEndDate: "", weeklyDataTable: [], }; } } const mtoJsQueryEngine = new MtoJsQueryEngine(); if (typeof window !== "undefined") { window.mtoJsQueryEngine = mtoJsQueryEngine; } export { mtoJsQueryEngine, MtoJsQueryEngine }; export default mtoJsQueryEngine;