/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/SummaryJsQueryEngine.js
1 928 строк
64 KB
Starolat Sergei
feat: модальное окно «Работы 4 уровня» для гамаков в Анализе отклонений
02 авг 2026, 12:48
02 авг 2026, 12:48
edc77bf
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview SummaryJsQueryEngine — JS-аналог SQL-агрегаций сводки (SYS-017) * @module SummaryJsQueryEngine * @version 1.2.0 * * Работает напрямую с in-memory индексами DataIndexer без SQL. */ import dataIndexer, { findCodeTypeByName, } from "./DataIndexer.js"; import weekIndexService from "./WeekIndexService.js"; import filterState from "./FilterStateManager.js"; import config from "../config.js"; import { detectOutliersMAD, loessSmooth, theilSenRegression, confidenceBand, } from "./TrendAnalysisService.js"; import { sanitizeLevelBaseFilters, applyCodeFilters, applyWbsFilter, applyResourceFilter, applyWeekRangeFilter, applyDrillDownFilters, applyDrillDownFilter, applyScheduleTypeFilter, } from "./FilterEngine.js"; /** * @typedef {import('../types.js').DatabaseIndexes} DatabaseIndexes * @typedef {import('../types.js').Activity} Activity * @typedef {import('../types.js').CodeFilterItem} CodeFilterItem * @typedef {import('../types.js').Filter} Filter * @typedef {import('../types.js').WeekRange} WeekRange * @typedef {import('../types.js').ProgressCategory} ProgressCategory * @typedef {import('../types.js').SCurveData} SCurveData * @typedef {import('../types.js').BarChartData} BarChartData * @typedef {import('../types.js').AutoLevelMetric} AutoLevelMetric * @typedef {import('../types.js').LevelContext} LevelContext */ // ============================================================================= // Helpers // ============================================================================= /** * Форматировать дату в DD.MM.YYYY * @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 {string|null|undefined} dateStr * @returns {number} */ function dateToNumber(dateStr) { if (!dateStr) return Number.NEGATIVE_INFINITY; const n = new Date(dateStr).getTime(); return isNaN(n) ? Number.NEGATIVE_INFINITY : n; } /** * @param {Activity} a * @returns {string|null} */ function effectiveStart(a) { return a.actualStartDate || a.startDate || null; } /** * @param {Activity} a * @returns {string|null} */ function effectiveEnd(a) { return a.actualEndDate || a.endDate || null; } /** * @param {Activity} a * @returns {boolean} */ function isCompleted(a) { return !!a.actualEndDate; } /** * @param {Activity} a * @returns {boolean} */ function isNotStarted(a) { return !a.actualStartDate; } /** * @param {Activity} a * @returns {boolean} */ function isInProgress(a) { return !!a.actualStartDate && !a.actualEndDate; } /** * Веха (start/finish milestone) — такие работы не имеют трудозатрат * и не должны участвовать в расчёте дат прогресс-виджета. * @param {Activity} a * @returns {boolean} */ function isMilestoneActivity(a) { const t = (a.taskType || "").toLowerCase(); return t === "tt_mile" || t === "tt_finmile"; } /** * Проверить, попадает ли неделя в диапазон weekRange с учётом * открытых границ (null/undefined = без ограничения). * @param {number} weekIndex * @param {WeekRange|null|undefined} weekRange * @returns {boolean} */ function isWeekInRange(weekIndex, weekRange) { if (!weekRange) return true; const start = weekRange.startIdx; const end = weekRange.endIdx; if (start != null && Number.isFinite(start) && weekIndex < start) { return false; } if (end != null && Number.isFinite(end) && weekIndex > end) { return false; } return true; } /** * @param {DatabaseIndexes} indexes * @returns {Set<number>} */ function getLaborResourceIds(indexes) { const set = new Set(); for (const [id, r] of indexes.resourcesById) { if (r.type === "labor") set.add(id); } return set; } /** * @param {DatabaseIndexes} indexes * @param {Set<number>} [ids] * @returns {Set<number>} */ function getMaterialResourceIds(indexes, ids) { const set = new Set(); for (const [id, r] of indexes.resourcesById) { if (r.type === "material" && (!ids || ids.has(id))) set.add(id); } return set; } // ============================================================================= // Resolver // ============================================================================= class SummaryJsQueryEngine { /** * Отфильтрованный набор работ. * @param {string} databaseId * @param {Object} options * @param {CodeFilterItem[]} [options.codeFilters] * @param {number[]} [options.wbsFilterIds] * @param {number[]} [options.resourceFilterIds] * @param {WeekRange} [options.weekRange] * @param {LevelContext|null} [options.levelContext] * @param {Filter[]} [options.privateFilters] * @param {Filter[]} [options.filters] * @param {'current'|'target'|'both'} [options.scheduleType] * @returns {Promise<Array<{activityId: number, scheduleType: string, activityCode: string}>>} */ async resolveActivities(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const set = this._resolveActivitySet(indexes, options); return Array.from(set).map((a) => ({ activityId: a.id, scheduleType: a.scheduleType || "current", activityCode: a.activityCode || "", })); } /** * @private * @param {DatabaseIndexes} indexes * @param {Object} options * @returns {Set<Activity>} */ _resolveActivitySet(indexes, options = {}) { const dataDomain = options.dataDomain || options.levelContext?.dataDomain || "manhours"; const baseFilters = sanitizeLevelBaseFilters( indexes, this._getEffectiveBaseFilters(options), ); let activities = new Set(indexes.activitiesById.values()); activities = applyScheduleTypeFilter( indexes, activities, options.scheduleType, ); activities = applyCodeFilters( indexes, activities, options.codeFilters || [], ); activities = applyWbsFilter( indexes, activities, options.wbsFilterIds || [], ); // Resource filter IDs apply to both material and manhours domains, // narrowing activities to those that have an assignment for the selected // material resources. This keeps summary analytics in sync with the WBS // table, where the same filter already works for both display modes. activities = applyResourceFilter( indexes, activities, options.resourceFilterIds || [], ); activities = applyWeekRangeFilter( indexes, activities, options.weekRange || null, ); // Level-scoped base filters (implicit scope). Applied after global filters // so drill-down filters can further narrow the scope. activities = applyDrillDownFilters(indexes, activities, baseFilters); // Implicit manhours scope: only level-3 activities unless the level already // defines a filter on "!Уровень графика". // If level filters are active, they already define the level scope; // disabling the "!Уровень графика" level filter means the user wants to // remove the level-3 restriction entirely, so we must NOT re-apply it. // The implicit scope is only applied when the project actually contains // the canonical "!Уровень графика" code type. if ( dataDomain === "manhours" && !options.ignoreGlobalFilters && !filterState.areLevelFiltersActive() ) { const hasLevelFilter = baseFilters.some( (f) => f.field && f.field.startsWith("code:") && f.field.replace("code:", "") === "!Уровень графика" && f.enabled !== false, ); if (!hasLevelFilter && findCodeTypeByName(indexes, "!Уровень графика")) { activities = applyDrillDownFilter(indexes, activities, { field: "code:!Уровень графика", condition: "equals", value: "3", enabled: true, }); } } const drillFilters = []; if (options.levelContext) { drillFilters.push(...(options.levelContext.inheritedFilters || [])); drillFilters.push(...(options.levelContext.levelFilters || [])); } if (options.privateFilters) drillFilters.push(...options.privateFilters); if (options.filters) drillFilters.push(...options.filters); activities = applyDrillDownFilters(indexes, activities, drillFilters); return activities; } /** * Возвращает effective baseFilters для уровня. * Приоритет отдаёт level-code-filters из FilterStateManager, чтобы * пользователь мог отключать/удалять scope уровня. Если level filters * не заданы — fallback на baseFilters из LevelContext (backward compat). * @param {Object} options * @returns {import('../types.js').Filter[]} * @private */ _getEffectiveBaseFilters(options) { // ignoreGlobalFilters: источник работает изолированно (модалки вроде // «Работы 4 уровня») — глобальные level-фильтры FilterStateManager // не должны подменять baseFilters запроса. if (options.ignoreGlobalFilters) { return options.baseFilters || options.levelContext?.baseFilters || []; } if (filterState.areLevelFiltersActive()) { return filterState.getEffectiveLevelCodeFilters().map((f) => ({ field: `code:${f.codeTypeId}`, condition: "equals", value: f.codeValueIds, enabled: f.enabled, })); } return options.baseFilters || options.levelContext?.baseFilters || []; } // ========================================================================= // Progress // ========================================================================= /** * Прогресс по единому отфильтрованному набору работ. * @param {string} databaseId * @param {Object} options * @param {string} [options.reportDate] * @param {number|null} [options.dataDateWeekIdx] * @param {'slice'|'range'} [options.calcMode] * @param {WeekRange} [options.weekRange] * @param {'mixed'|'baseline'|'current'|'absolute'} [options.normalizationMode] * @param {string|null} [options.effectiveWeekEnd] * @returns {Promise<ProgressCategory>} */ async getProgressData(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const activities = this._resolveActivitySet(indexes, options); const reportDate = options.reportDate || this._getProjectDataDate(indexes); const reportWeekIdx = reportDate ? weekIndexService.getWeekIndex(reportDate) : null; const effectiveWeekIdx = this._resolveEffectiveWeekIdx( indexes, reportWeekIdx, options, ); const effectiveWeekEnd = options.effectiveWeekEnd || (effectiveWeekIdx != null ? weekIndexService.indexToWeekEnd(effectiveWeekIdx) : null); const normMode = options.normalizationMode || config.DEFAULT_NORMALIZATION_MODE || "mixed"; const weekRange = options.weekRange || null; const laborIds = getLaborResourceIds(indexes); const spread = indexes.spread; let totalPlan = 0; let totalFact = 0; let cumPlan = 0; let cumFact = 0; let periodPlan = 0; let periodFact = 0; /** @type {Map<number, {planUnits: number, actualUnits: number, remainingUnits: number}>} */ const weeklyMap = new Map(); // Работы с трудозатратами (есть labor-назначения с ненулевыми единицами). // Только они участвуют в расчёте дат виджета — вехи и работы без // трудозатрат даты не «растягивают». /** @type {Set<number>} */ const activityIdsWithUnits = new Set(); for (const a of activities) { const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null || !laborIds.has(ra.resourceId)) continue; const range = indexes.spreadIndexByAssignment.get(ra.assignmentId); if (!range) continue; for (let i = range.start; i < range.end; i++) { const wi = spread.weekIndices[i]; if (!isWeekInRange(wi, weekRange)) continue; const actual = spread.actualUnits[i]; const remaining = spread.remainingUnits[i]; if (actual + remaining > 0) activityIdsWithUnits.add(a.id); if (a.scheduleType === "target") { const planU = actual + remaining; totalPlan += planU; if (effectiveWeekIdx != null && wi <= effectiveWeekIdx) { cumPlan += planU; } if (effectiveWeekIdx != null && wi === effectiveWeekIdx) { periodPlan += planU; } const entry = this._getWeeklyEntry(weeklyMap, wi); entry.planUnits += planU; } else { const factU = actual; const totalU = actual + remaining; totalFact += totalU; if (effectiveWeekIdx != null && wi <= effectiveWeekIdx) { cumFact += factU; } if (effectiveWeekIdx != null && wi === effectiveWeekIdx) { periodFact += factU; } const entry = this._getWeeklyEntry(weeklyMap, wi); entry.actualUnits += factU; entry.remainingUnits += remaining; } } } } const result = { accumulatedPlanPrevWeek: 0, accumulatedFactPrevWeek: 0, periodPlanPrevWeek: 0, periodFactPrevWeek: 0, cumulativePlanCurrentWeek: 0, cumulativeFactCurrentWeek: 0, delta: 0, trend: null, totalPlan, totalFact, startDate: null, endDate: null, targetStartDate: null, targetEndDate: null, weeklyDataTable: this._buildWeeklyDataTable(indexes, weeklyMap), reportDate, effectiveWeekEnd, }; const planDenominator = normMode === "current" ? totalFact : totalPlan; const factDenominator = normMode === "baseline" ? totalPlan : totalFact; if (normMode === "absolute") { result.cumulativePlanCurrentWeek = parseFloat(cumPlan.toFixed(1)); result.periodPlanPrevWeek = parseFloat(periodPlan.toFixed(1)); result.accumulatedPlanPrevWeek = result.cumulativePlanCurrentWeek; result.cumulativeFactCurrentWeek = parseFloat(cumFact.toFixed(1)); result.periodFactPrevWeek = parseFloat(periodFact.toFixed(1)); result.accumulatedFactPrevWeek = result.cumulativeFactCurrentWeek; result.delta = parseFloat((cumFact - cumPlan).toFixed(2)); } else { // Если остались незавершённые трудозатраты (cum < знаменателя), но // округление до 1 знака даёт 100,0% — показываем 99,9% до тех пор, // пока остаток не станет нулевым (план и факт). const capHundred = (/** @type {number} */ cum, /** @type {number} */ denom) => { const pct = (cum / denom) * 100; const rounded = parseFloat(pct.toFixed(1)); if (rounded === 100 && cum < denom) return 99.9; return rounded; }; if (planDenominator > 0) { result.cumulativePlanCurrentWeek = capHundred(cumPlan, planDenominator); result.periodPlanPrevWeek = parseFloat( ((periodPlan / planDenominator) * 100).toFixed(1), ); result.accumulatedPlanPrevWeek = result.cumulativePlanCurrentWeek; } if (factDenominator > 0) { result.cumulativeFactCurrentWeek = capHundred(cumFact, factDenominator); result.periodFactPrevWeek = parseFloat( ((periodFact / factDenominator) * 100).toFixed(1), ); result.accumulatedFactPrevWeek = result.cumulativeFactCurrentWeek; } result.delta = parseFloat( ( result.cumulativeFactCurrentWeek - result.cumulativePlanCurrentWeek ).toFixed(2), ); } // Dates — только по работам с трудозатратами, вехи исключаются. let startMin = null; let endMax = null; let targetStartMin = null; let targetEndMax = null; for (const a of activities) { if (isMilestoneActivity(a) || !activityIdsWithUnits.has(a.id)) continue; if (a.scheduleType === "current") { const s = effectiveStart(a); const e = effectiveEnd(a); if (s && (!startMin || s < startMin)) startMin = s; if (e && (!endMax || e > endMax)) endMax = e; } else if (a.scheduleType === "target") { const s = effectiveStart(a); const e = effectiveEnd(a); if (s && (!targetStartMin || s < targetStartMin)) targetStartMin = s; if (e && (!targetEndMax || e > targetEndMax)) targetEndMax = e; } } result.startDate = formatDateToDDMMYYYY(startMin); result.endDate = formatDateToDDMMYYYY(endMax); result.targetStartDate = formatDateToDDMMYYYY(targetStartMin); result.targetEndDate = formatDateToDDMMYYYY(targetEndMax); return result; } /** * @private * @param {Map<number, {planUnits: number, actualUnits: number, remainingUnits: number}>} map * @param {number} weekIndex */ _getWeeklyEntry(map, weekIndex) { let entry = map.get(weekIndex); if (!entry) { entry = { planUnits: 0, actualUnits: 0, remainingUnits: 0 }; map.set(weekIndex, entry); } return entry; } /** * @private * @param {DatabaseIndexes} indexes * @param {Map<number, {planUnits: number, actualUnits: number, remainingUnits: number}>} weeklyMap */ _buildWeeklyDataTable(indexes, weeklyMap) { const sorted = Array.from(weeklyMap.keys()).sort((a, b) => a - b); return sorted.map((wi) => { const entry = weeklyMap.get(wi); const info = weekIndexService.getWeekInfo(indexes.databaseId, wi); return { weekStartDate: info?.weekStart || String(wi), weekEndDate: info?.weekEnd || "", planUnits: entry?.planUnits || 0, actualUnits: entry?.actualUnits || 0, remainingUnits: entry?.remainingUnits || 0, }; }); } // ========================================================================= // Bar Chart // ========================================================================= /** * Данные bar-chart по количеству завершённых работ. * @param {string} databaseId * @param {Object} options * @param {string} [options.reportDate] * @param {number|null} [options.dataDateWeekIdx] * @param {'slice'|'range'} [options.calcMode] * @param {WeekRange} [options.weekRange] * @param {string|null} [options.effectiveWeekEnd] * @returns {Promise<BarChartData>} */ async getBarChartData(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const activities = this._resolveActivitySet(indexes, options); const reportDate = options.reportDate || this._getProjectDataDate(indexes); const reportWeekIdx = reportDate ? weekIndexService.getWeekIndex(reportDate) : null; const effectiveWeekIdx = this._resolveEffectiveWeekIdx( indexes, reportWeekIdx, options, ); const effectiveWeekEnd = options.effectiveWeekEnd || (effectiveWeekIdx != null ? weekIndexService.indexToWeekEnd(effectiveWeekIdx) : null); const cutoff = effectiveWeekEnd || reportDate || "2099-12-31"; let planCount = 0; let factCount = 0; for (const a of activities) { if (a.scheduleType === "target" && a.endDate && a.endDate <= cutoff) { planCount++; } else if ( a.scheduleType === "current" && a.actualEndDate && a.actualEndDate <= cutoff ) { factCount++; } } return { categories: ["План", "Факт"], plan: [planCount, null], fact: [null, factCount], total: planCount + factCount, }; } // ========================================================================= // S-Curve // ========================================================================= /** * S-кривая трудозатрат. * @param {string} databaseId * @param {Object} options * @param {string} [options.reportDate] * @param {number|null} [options.dataDateWeekIdx] * @param {'slice'|'range'} [options.calcMode] * @param {WeekRange} [options.weekRange] * @param {'mixed'|'baseline'|'current'|'absolute'} [options.normalizationMode] * @returns {Promise<SCurveData>} */ async getSCurveData(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const activities = this._resolveActivitySet(indexes, options); const reportDate = options.reportDate || this._getProjectDataDate(indexes); const reportWeekIdx = reportDate ? weekIndexService.getWeekIndex(reportDate) : null; const effectiveWeekIdx = this._resolveEffectiveWeekIdx( indexes, reportWeekIdx, options, ); const normMode = options.normalizationMode || config.DEFAULT_NORMALIZATION_MODE || "mixed"; const weekRange = options.weekRange || null; const laborIds = getLaborResourceIds(indexes); const spread = indexes.spread; /** @type {Map<number, {plan: number, actual: number, remaining: number}>} */ const weeklyMap = new Map(); for (const a of activities) { const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null || !laborIds.has(ra.resourceId)) continue; const range = indexes.spreadIndexByAssignment.get(ra.assignmentId); if (!range) continue; for (let i = range.start; i < range.end; i++) { const wi = spread.weekIndices[i]; if (wi == null || wi <= 0) continue; if (!isWeekInRange(wi, weekRange)) continue; const entry = this._getSCurveWeeklyEntry(weeklyMap, wi); const actual = spread.actualUnits[i]; const remaining = spread.remainingUnits[i]; if (a.scheduleType === "target") { entry.plan += actual + remaining; } else { entry.actual += actual; entry.remaining += remaining; } } } } const sortedWeeks = Array.from(weeklyMap.keys()).sort((a, b) => a - b); if (sortedWeeks.length === 0) { return { xAxis: [], baseline: [], actual: [], forecast: [], trend: [], trendForecast: [], outliers: [], confidenceUpper: [], confidenceLower: [], theilSen: null, normalizationMode: normMode, reportDate, reportWeekIdx: effectiveWeekIdx ?? reportWeekIdx ?? null, }; } let totalBaseline = 0; let totalCurrent = 0; for (const wi of sortedWeeks) { const e = weeklyMap.get(wi); totalBaseline += e?.plan || 0; totalCurrent += (e?.actual || 0) + (e?.remaining || 0); } /** @type {Array<{weekIdx: number, plan: number, actual: number, remaining: number, cumPlan: number, cumActual: number}>} */ const weekData = []; let cumPlan = 0; let cumActual = 0; for (const wi of sortedWeeks) { const e = weeklyMap.get(wi); const planU = e?.plan || 0; const actualU = e?.actual || 0; const remainingU = e?.remaining || 0; cumPlan += planU; cumActual += actualU; weekData.push({ weekIdx: wi, plan: planU, actual: actualU, remaining: remainingU, cumPlan, cumActual, }); } let actualAtReportWeek = 0; for (const wd of weekData) { if (effectiveWeekIdx == null || wd.weekIdx <= effectiveWeekIdx) { actualAtReportWeek = wd.cumActual; } } const xAxis = []; const baseline = []; const actual = []; const forecast = []; let cumRemainingFromReport = 0; for (const wd of weekData) { const monday = weekIndexService.getMondayFromIndex(wd.weekIdx); const label = `${String(monday.getUTCDate()).padStart(2, "0")}.${String(monday.getUTCMonth() + 1).padStart(2, "0")}.${String(monday.getUTCFullYear()).slice(-2)}`; xAxis.push(label); let baseVal; let actVal; if (normMode === "absolute") { baseVal = parseFloat(wd.cumPlan.toFixed(1)); actVal = parseFloat(wd.cumActual.toFixed(1)); } else { const baseDenom = normMode === "current" ? totalCurrent : totalBaseline; const actDenom = normMode === "baseline" ? totalBaseline : totalCurrent; baseVal = baseDenom > 0 ? parseFloat(((wd.cumPlan / baseDenom) * 100).toFixed(1)) : 0; actVal = actDenom > 0 ? parseFloat(((wd.cumActual / actDenom) * 100).toFixed(1)) : 0; } baseline.push(baseVal); if (effectiveWeekIdx == null || wd.weekIdx <= effectiveWeekIdx) { actual.push(actVal); } else { actual.push(null); } if (effectiveWeekIdx != null && wd.weekIdx > effectiveWeekIdx) { cumRemainingFromReport += wd.remaining; const foreAbs = actualAtReportWeek + cumRemainingFromReport; let foreVal; if (normMode === "absolute") { foreVal = parseFloat(foreAbs.toFixed(1)); } else { const foreDenom = normMode === "baseline" ? totalBaseline : totalCurrent; foreVal = foreDenom > 0 ? parseFloat(((foreAbs / foreDenom) * 100).toFixed(1)) : 0; } forecast.push(foreVal); } else { forecast.push(null); } } // Trend analysis let trend = []; let trendForecast = []; let outliers = []; let confidenceUpper = []; let confidenceLower = []; let theilSen = null; const denomTrend = normMode === "absolute" ? 1 : normMode === "baseline" ? totalBaseline : totalCurrent; if (weekData.length > 0 && (normMode === "absolute" || denomTrend > 0)) { const periodActual = weekData.map((d) => d.actual); const madResult = detectOutliersMAD(periodActual, 3.5); outliers = madResult.isOutlier; let lastFactXIndex = -1; for (let i = 0; i < weekData.length; i++) { if ( effectiveWeekIdx == null || weekData[i].weekIdx <= effectiveWeekIdx ) { lastFactXIndex = i; } } const factSlice = lastFactXIndex >= 0 ? weekData.slice(0, lastFactXIndex + 1) : weekData; const actualPoints = factSlice .map((d, i) => ({ x: i, y: d.cumActual })) .filter((p) => p.y > 0); if (actualPoints.length >= 3) { const loessAbs = loessSmooth( actualPoints.map((p) => p.x), actualPoints.map((p) => p.y), 0.35, ); const loessMap = new Map( actualPoints.map((p, i) => [p.x, loessAbs[i]]), ); trend = weekData.map((_, i) => { const abs = loessMap.get(i); if (abs == null) return null; if (normMode === "absolute") return parseFloat(abs.toFixed(1)); return parseFloat(((abs / denomTrend) * 100).toFixed(1)); }); const { upper: upAbs, lower: lowAbs } = confidenceBand( actualPoints.map((p) => p.y), loessAbs, 2, ); const upMap = new Map(actualPoints.map((p, i) => [p.x, upAbs[i]])); const lowMap = new Map(actualPoints.map((p, i) => [p.x, lowAbs[i]])); confidenceUpper = weekData.map((_, i) => { const abs = upMap.get(i); if (abs == null) return null; if (normMode === "absolute") return parseFloat(abs.toFixed(1)); return parseFloat(((abs / denomTrend) * 100).toFixed(1)); }); confidenceLower = weekData.map((_, i) => { const abs = lowMap.get(i); if (abs == null) return null; if (normMode === "absolute") return parseFloat(abs.toFixed(1)); return parseFloat(((abs / denomTrend) * 100).toFixed(1)); }); } const recentWindow = 12; const recentPoints = actualPoints.slice(-recentWindow); if (recentPoints.length >= 2) { const ts = theilSenRegression(recentPoints); const lastActualPoint = actualPoints[actualPoints.length - 1]; const lastIdx = lastActualPoint ? lastActualPoint.x : -1; const anchorAbs = lastActualPoint ? lastActualPoint.y : 0; const regressionAtAnchor = ts.predict(lastIdx); trendForecast = weekData.map((_, i) => { if (i < lastIdx) return null; if (i === lastIdx) return actual[lastIdx]; const predictedAbs = ts.predict(i); const shiftedAbs = anchorAbs + (predictedAbs - regressionAtAnchor); if (normMode === "absolute") { return parseFloat(shiftedAbs.toFixed(1)); } const clampedAbs = normMode === "current" ? Math.min(shiftedAbs, totalCurrent) : shiftedAbs; return parseFloat(((clampedAbs / denomTrend) * 100).toFixed(1)); }); const totalForFinish = normMode === "baseline" ? totalBaseline : totalCurrent; const estimatedFinishXIndex = ts.slope > 0 ? Math.ceil(lastIdx + (totalForFinish - anchorAbs) / ts.slope) : null; if ( estimatedFinishXIndex != null && estimatedFinishXIndex >= weekData.length ) { const lastWeekIdx = weekData[weekData.length - 1].weekIdx; const extraCount = estimatedFinishXIndex - weekData.length + 1; const safeExtraCount = Math.min(extraCount, 52); for (let i = 0; i < safeExtraCount; i++) { const wIdx = lastWeekIdx + 1 + i; const monday = weekIndexService.getMondayFromIndex(wIdx); const label = `${String(monday.getUTCDate()).padStart(2, "0")}.${String(monday.getUTCMonth() + 1).padStart(2, "0")}.${String(monday.getUTCFullYear()).slice(-2)}`; xAxis.push(label); baseline.push(null); actual.push(null); forecast.push(null); trend.push(null); outliers.push(false); confidenceUpper.push(null); confidenceLower.push(null); const predictedAbs = ts.predict(weekData.length + i); const shiftedAbs = anchorAbs + (predictedAbs - regressionAtAnchor); if (normMode === "absolute") { trendForecast.push(parseFloat(shiftedAbs.toFixed(1))); } else { const clampedAbs = normMode === "current" ? Math.min(shiftedAbs, totalCurrent) : shiftedAbs; trendForecast.push( parseFloat(((clampedAbs / denomTrend) * 100).toFixed(1)), ); } } } const estimatedFinishWeekIndex = estimatedFinishXIndex != null && weekData[estimatedFinishXIndex] ? weekData[estimatedFinishXIndex].weekIdx : null; theilSen = { slope: ts.slope, intercept: ts.intercept, estimatedFinishWeekIndex, estimatedFinishXIndex: estimatedFinishXIndex != null && estimatedFinishXIndex >= 0 && estimatedFinishXIndex < xAxis.length ? estimatedFinishXIndex : null, }; } } return { xAxis, baseline, actual, forecast, trend, trendForecast, outliers, confidenceUpper, confidenceLower, theilSen, normalizationMode: normMode, reportDate, reportWeekIdx: effectiveWeekIdx ?? reportWeekIdx ?? null, }; } /** * @private * @param {Map<number, {plan: number, actual: number, remaining: number}>} map * @param {number} weekIndex */ _getSCurveWeeklyEntry(map, weekIndex) { let entry = map.get(weekIndex); if (!entry) { entry = { plan: 0, actual: 0, remaining: 0 }; map.set(weekIndex, entry); } return entry; } // ========================================================================= // Auto Level Metrics // ========================================================================= /** * Метрики для auto-уровня. * @param {string} databaseId * @param {string|number} codeTypeNameOrId * @param {Object} options * @param {'current'|'target'|'both'} [options.scheduleType='both'] * @param {WeekRange} [options.weekRange] * @param {Filter[]} [options.filters=[]] * @param {'codeOrder'|'deviation'|'actual'|'plan'} [options.sortBy='codeOrder'] * @param {'asc'|'desc'} [options.sortDir='asc'] * @param {'labor'|'nonlabor'|'material'} [options.resourceType='labor'] * @param {number[]} [options.resourceFilterIds=[]] * @param {number[]} [options.wbsFilterIds=[]] * @param {string} [options.reportDate] * @param {'manhours'|'material'} [options.dataDomain='manhours'] * @param {Filter[]} [options.baseFilters=[]] * @returns {Promise<AutoLevelMetric[]>} */ async getAutoLevelMetrics(databaseId, codeTypeNameOrId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const typeId = this._resolveCodeTypeId(indexes, codeTypeNameOrId); if (typeId == null) { console.warn( `[SummaryJsQueryEngine] Code type not found: ${codeTypeNameOrId}`, ); return []; } const resourceType = options.resourceType && ["labor", "nonlabor", "material"].includes(options.resourceType) ? options.resourceType : "labor"; const sortBy = options.sortBy || "codeOrder"; const sortDir = options.sortDir === "desc" ? "desc" : "asc"; const baseOptions = { scheduleType: options.scheduleType || "both", weekRange: options.weekRange || null, filters: options.filters || [], resourceFilterIds: options.resourceFilterIds || [], wbsFilterIds: options.wbsFilterIds || [], dataDomain: options.dataDomain || (resourceType === "material" ? "material" : "manhours"), baseFilters: options.baseFilters || [], }; const filteredActivities = this._resolveActivitySet(indexes, baseOptions); const reportDate = options.reportDate || this._getProjectDataDate(indexes); const reportWeekIdx = reportDate ? weekIndexService.getWeekIndex(reportDate) : null; const valuesByType = indexes.codeValuesByType.get(typeId); if (!valuesByType) return []; /** @type {AutoLevelMetric[]} */ const metrics = []; for (const codeValue of valuesByType.values()) { const valueActivitiesAll = indexes.activitiesByCodeValue.get( codeValue.valueId, ); if (!valueActivitiesAll || valueActivitiesAll.size === 0) { metrics.push(this._emptyAutoLevelMetric(codeValue, indexes.databaseId)); continue; } const valueActivities = new Set( Array.from(valueActivitiesAll) .map((id) => indexes.activitiesById.get(id)) .filter((a) => a && filteredActivities.has(a)), ); if (valueActivities.size === 0) { metrics.push(this._emptyAutoLevelMetric(codeValue, indexes.databaseId)); continue; } metrics.push( this._buildAutoLevelMetric( indexes, codeValue, valueActivities, resourceType, reportWeekIdx, ), ); } // Sorting const order = sortDir === "desc" ? -1 : 1; metrics.sort((a, b) => { switch (sortBy) { case "deviation": { const da = a.totalPlan > 0 ? (a.cumFact - a.cumPlan) / a.totalPlan : 0; const db = b.totalPlan > 0 ? (b.cumFact - b.cumPlan) / b.totalPlan : 0; return (da - db) * order; } case "actual": return (a.factManhours - b.factManhours) * order; case "plan": return (a.planManhours - b.planManhours) * order; case "codeOrder": default: return ( (a.sortOrder - b.sortOrder || a.shortName.localeCompare(b.shortName)) * order ); } }); return metrics; } /** * @private * @param {DatabaseIndexes} indexes * @param {import('../types.js').CodeValue} codeValue * @param {string} databaseId * @returns {AutoLevelMetric} */ _emptyAutoLevelMetric(codeValue, databaseId) { return { valueId: codeValue.valueId, shortName: codeValue.shortName, description: codeValue.description, color: codeValue.color || "", sortOrder: codeValue.sortOrder || 0, count: 0, planManhours: 0, factManhours: 0, remainingManhours: 0, avgPercentComplete: 0, completedCount: 0, notStartedCount: 0, inProgressCount: 0, earliestStart: null, latestEnd: null, targetEarliestStart: null, targetLatestEnd: null, cumPlan: 0, cumFact: 0, totalPlan: 0, totalFact: 0, weeklyData: [], }; } /** * @private * @param {DatabaseIndexes} indexes * @param {import('../types.js').CodeValue} codeValue * @param {Set<Activity>} activities * @param {'labor'|'nonlabor'|'material'} resourceType * @param {number|null} reportWeekIdx * @returns {AutoLevelMetric} */ _buildAutoLevelMetric( indexes, codeValue, activities, resourceType, reportWeekIdx, ) { const resourceIds = new Set(); for (const [id, r] of indexes.resourcesById) { if (r.type === resourceType) resourceIds.add(id); } const isMaterial = resourceType === "material"; let planManhours = 0; let factManhours = 0; let remainingManhours = 0; let completedCount = 0; let notStartedCount = 0; let inProgressCount = 0; let earliestStart = null; let latestEnd = null; let targetEarliestStart = null; let targetLatestEnd = null; /** @type {Map<number, {planUnits: number, actualUnits: number, remainingUnits: number}>} */ const weeklyMap = new Map(); let cumPlan = 0; let cumFact = 0; let totalPlan = 0; let totalFact = 0; /** @type {Set<string>} */ const distinctUnits = new Set(); for (const a of activities) { planManhours += a.totalManhours || 0; factManhours += a.actualManhours || 0; remainingManhours += a.remainingManhours || 0; // Счётчики статусов — только по работам ТГ (current): у работ ЦП // нет фактических дат, иначе они всегда попадали бы в notStarted и // «все работы завершены» никогда не наступало бы. if (a.scheduleType !== "target") { if (isCompleted(a)) completedCount++; else if (isNotStarted(a)) notStartedCount++; else if (isInProgress(a)) inProgressCount++; } const s = effectiveStart(a); const e = effectiveEnd(a); const assignments = indexes.assignmentsByActivity.get(a.id) || []; // Даты виджета считаем только по работам с трудозатратами (по ресурсам // уровня) — вехи и работы без назначений даты не «растягивают». let hasUnits = false; for (const ra of assignments) { if (ra.resourceId == null || !resourceIds.has(ra.resourceId)) continue; const res = indexes.resourcesById.get(ra.resourceId); distinctUnits.add(this._resourceUnitName(res)); const range = indexes.spreadIndexByAssignment.get(ra.assignmentId); if (!range) continue; const spread = indexes.spread; for (let i = range.start; i < range.end; i++) { const wi = spread.weekIndices[i]; const actual = spread.actualUnits[i]; const remaining = spread.remainingUnits[i]; const planU = actual + remaining; if (planU > 0) hasUnits = true; const entry = this._getWeeklyEntry(weeklyMap, wi); if (a.scheduleType === "target") { entry.planUnits += planU; totalPlan += planU; if (reportWeekIdx != null && wi <= reportWeekIdx) cumPlan += planU; } else { entry.actualUnits += actual; entry.remainingUnits += remaining; totalFact += planU; if (reportWeekIdx != null && wi <= reportWeekIdx) cumFact += actual; } } } if (hasUnits && !isMilestoneActivity(a)) { if (a.scheduleType === "current") { if (s && (!earliestStart || s < earliestStart)) earliestStart = s; if (e && (!latestEnd || e > latestEnd)) latestEnd = e; } else if (a.scheduleType === "target") { if (s && (!targetEarliestStart || s < targetEarliestStart)) targetEarliestStart = s; if (e && (!targetLatestEnd || e > targetLatestEnd)) targetLatestEnd = e; } } } const count = activities.size; // For material domain the useful quantities are plan/actual units (not manhours). const planQty = isMaterial ? totalPlan : planManhours; const actualQty = isMaterial ? cumFact : factManhours; const unitName = distinctUnits.size === 1 ? distinctUnits.values().next().value || "" : isMaterial ? "" : "чел.час"; const avgPercentComplete = isMaterial ? totalPlan > 0 ? parseFloat(((cumFact / totalPlan) * 100).toFixed(2)) : 0 : planManhours > 0 ? parseFloat(((factManhours / planManhours) * 100).toFixed(2)) : 0; const sortedWeeks = Array.from(weeklyMap.keys()).sort((a, b) => a - b); const weeklyData = sortedWeeks.map((wi) => { const entry = weeklyMap.get(wi); return { weekIndex: wi, weekStartDate: weekIndexService .getMondayFromIndex(wi) .toISOString() .split("T")[0], weekEndDate: weekIndexService.indexToWeekEnd(wi), planUnits: entry?.planUnits || 0, actualUnits: entry?.actualUnits || 0, remainingUnits: entry?.remainingUnits || 0, }; }); return { valueId: codeValue.valueId, shortName: codeValue.shortName, description: codeValue.description, color: codeValue.color || "", sortOrder: codeValue.sortOrder || 0, count, planManhours, factManhours, remainingManhours, avgPercentComplete, completedCount, notStartedCount, inProgressCount, earliestStart, latestEnd, targetEarliestStart, targetLatestEnd, cumPlan, cumFact, totalPlan, totalFact, weeklyData, planQty, actualQty, unitName, activityCount: count, }; } /** * @private * @param {DatabaseIndexes} indexes * @param {string|number} codeTypeNameOrId * @returns {number|undefined} */ _resolveCodeTypeId(indexes, codeTypeNameOrId) { if ( typeof codeTypeNameOrId === "number" || /^\d+$/.test(String(codeTypeNameOrId)) ) { const id = Number(codeTypeNameOrId); if (indexes.codeTypeById.has(id)) return id; } for (const type of indexes.codeTypeById.values()) { if (type.name === codeTypeNameOrId) return type.typeId; } return undefined; } // ========================================================================= // Material Volumes // ========================================================================= /** * Прогресс по физическим объёмам (материальные ресурсы). * JS Data Engine aggregate for material volumes by code value. * @param {string} databaseId * @param {Object} options * @param {import('../types.js').CodeFilterItem[]} [options.codeFilters] * @param {number[]} [options.wbsFilterIds] * @param {number[]} [options.resourceFilterIds] * @param {import('../types.js').WeekRange} [options.weekRange] * @returns {Promise<import('../types.js').VolumeProgressData>} */ async getMaterialVolumeProgressData(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const materialIds = getMaterialResourceIds(indexes); const activities = this._resolveActivitySet( indexes, this._materialBaseOptions(indexes, options), ); /** @type {Map<number, {resourceId: number, resourceName: string, unitName: string, planQty: number, actualQty: number, remainingQty: number, seenActivities: Set<number>, activityCount: number}>} */ const agg = new Map(); for (const a of activities) { const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null || !materialIds.has(ra.resourceId)) continue; const res = indexes.resourcesById.get(ra.resourceId); if (!agg.has(ra.resourceId)) { agg.set(ra.resourceId, { resourceId: ra.resourceId, resourceName: res?.name || `Ресурс ${ra.resourceId}`, unitName: this._resourceUnitName(res), planQty: 0, actualQty: 0, remainingQty: 0, seenActivities: new Set(), activityCount: 0, }); } const entry = agg.get(ra.resourceId); if (a.scheduleType === "target") { entry.planQty += ra.targetUnits || 0; } else if (a.scheduleType === "current") { entry.actualQty += ra.actualUnits || 0; entry.remainingQty += ra.remainingUnits || 0; } if (!entry.seenActivities.has(a.id)) { entry.seenActivities.add(a.id); entry.activityCount++; } } } const items = Array.from(agg.values()) .map((e) => ({ resourceId: e.resourceId, resourceName: e.resourceName, unitName: e.unitName, planQty: e.planQty, actualQty: e.actualQty, remainingQty: e.remainingQty, percentComplete: e.planQty > 0 ? parseFloat(((e.actualQty / e.planQty) * 100).toFixed(2)) : 0, activityCount: e.activityCount, })) .sort((a, b) => b.planQty - a.planQty); const totalPlan = items.reduce((s, r) => s + r.planQty, 0); const totalActual = items.reduce((s, r) => s + r.actualQty, 0); const totalRemaining = items.reduce((s, r) => s + r.remainingQty, 0); const distinctUnits = new Set(items.map((r) => r.unitName)); return { totalPlan, totalActual, totalRemaining, percentComplete: totalPlan > 0 ? parseFloat(((totalActual / totalPlan) * 100).toFixed(2)) : 0, items, totalMaterials: items.length, totalActivities: items.reduce((s, r) => s + r.activityCount, 0), hasMixedUnits: distinctUnits.size > 1, }; } /** * S-кривая по физическим объёмам (материальные ресурсы). * JS Data Engine material S-curve data. * @param {string} databaseId * @param {Object} options * @param {import('../types.js').CodeFilterItem[]} [options.codeFilters] * @param {number[]} [options.wbsFilterIds] * @param {number[]} [options.resourceFilterIds] * @param {import('../types.js').WeekRange} [options.weekRange] * @param {string} [options.reportDate] * @returns {Promise<import('../types.js').VolumeSCurveData>} */ async getMaterialVolumeSCurveData(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const materialIds = getMaterialResourceIds(indexes); const activities = this._resolveActivitySet( indexes, this._materialBaseOptions(indexes, options), ); /** @type {Map<number, Map<number, {resourceName: string, unitName: string, plan: number, actual: number, remaining: number}>>} */ const weekResourceMap = new Map(); /** @type {Map<number, {resourceName: string, unitName: string, totalPlan: number, totalActual: number, totalRemaining: number}>} */ const resourceTotals = new Map(); const distinctUnits = new Set(); for (const a of activities) { const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null || !materialIds.has(ra.resourceId)) continue; const res = indexes.resourcesById.get(ra.resourceId); const resourceName = res?.name || `Ресурс ${ra.resourceId}`; const unitName = this._resourceUnitName(res); distinctUnits.add(unitName); if (!resourceTotals.has(ra.resourceId)) { resourceTotals.set(ra.resourceId, { resourceName, unitName, totalPlan: 0, totalActual: 0, totalRemaining: 0, }); } const totals = resourceTotals.get(ra.resourceId); 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; const actual = indexes.spread.actualUnits[i]; const remaining = indexes.spread.remainingUnits[i]; let planU = 0; let actualU = 0; let remainingU = 0; if (a.scheduleType === "target") { planU = actual + remaining; } else { actualU = actual; remainingU = remaining; } if (!weekResourceMap.has(wi)) { weekResourceMap.set(wi, new Map()); } const resMap = weekResourceMap.get(wi); if (!resMap.has(ra.resourceId)) { resMap.set(ra.resourceId, { resourceName, plan: 0, actual: 0, remaining: 0, unitName, }); } const entry = resMap.get(ra.resourceId); entry.plan += planU; entry.actual += actualU; entry.remaining += remainingU; totals.totalPlan += planU; totals.totalActual += actualU; totals.totalRemaining += remainingU; } } } const sortedWeeks = Array.from(weekResourceMap.keys()).sort( (a, b) => a - b, ); if (sortedWeeks.length === 0) { return { xAxis: [], categories: [], totalPlanPct: [], totalActualPct: [], totalForecastPct: [], totalPlanSum: 0, totalActualSum: 0, totalForecastSum: 0, hasMixedUnits: false, reportDate: null, reportWeekIdx: null, }; } const sortedResourceIds = Array.from(resourceTotals.keys()).sort( (a, b) => (resourceTotals.get(b)?.totalPlan || 0) - (resourceTotals.get(a)?.totalPlan || 0), ); const xAxis = []; /** @type {Map<number, number[]>} */ const resourcePeriodPlan = new Map(); /** @type {Map<number, number[]>} */ const resourcePeriodActual = new Map(); /** @type {Map<number, number[]>} */ const resourcePeriodRemaining = new Map(); const weekTotalPlan = []; const weekTotalActual = []; const weekTotalRemaining = []; const cumTotalPlan = []; const cumTotalActual = []; let runningPlan = 0; let runningActual = 0; for (const wi of sortedWeeks) { const resMap = weekResourceMap.get(wi) || new Map(); let weekPlan = 0; let weekActual = 0; let weekRemaining = 0; for (const rid of sortedResourceIds) { const entry = resMap.get(rid) || { plan: 0, actual: 0, remaining: 0 }; if (!resourcePeriodPlan.has(rid)) resourcePeriodPlan.set(rid, []); if (!resourcePeriodActual.has(rid)) resourcePeriodActual.set(rid, []); if (!resourcePeriodRemaining.has(rid)) resourcePeriodRemaining.set(rid, []); resourcePeriodPlan.get(rid).push(entry.plan); resourcePeriodActual.get(rid).push(entry.actual); resourcePeriodRemaining.get(rid).push(entry.remaining); weekPlan += entry.plan; weekActual += entry.actual; weekRemaining += entry.remaining; } runningPlan += weekPlan; runningActual += weekActual; const info = weekIndexService.getWeekInfo(indexes.databaseId, wi); xAxis.push( info?.weekStart ? formatDateToDDMMYYYY(info.weekStart) : String(wi), ); weekTotalPlan.push(weekPlan); weekTotalActual.push(weekActual); weekTotalRemaining.push(weekRemaining); cumTotalPlan.push(runningPlan); cumTotalActual.push(runningActual); } const totalPlanSum = runningPlan; const totalActualSum = runningActual; const totalRemainingSum = weekTotalRemaining.reduce((s, v) => s + v, 0); const totalCurrentSum = totalActualSum + totalRemainingSum; const totalForecastSum = totalCurrentSum; const reportDate = options.reportDate || this._getProjectDataDate(indexes); const reportWeekIdx = reportDate ? weekIndexService.getWeekIndex(reportDate) : null; let actualAtReportWeek = 0; const reportAxisIndex = reportWeekIdx != null ? sortedWeeks.findIndex((wi) => wi === reportWeekIdx) : -1; if (reportWeekIdx != null) { for (let i = 0; i < sortedWeeks.length; i++) { if (sortedWeeks[i] <= reportWeekIdx) { actualAtReportWeek = cumTotalActual[i]; } } } else { actualAtReportWeek = totalActualSum; } const totalActualPct = []; const totalForecastPct = []; let cumRemainingFromReport = 0; for (let i = 0; i < sortedWeeks.length; i++) { const wi = sortedWeeks[i]; if (reportWeekIdx == null || wi <= reportWeekIdx) { totalActualPct.push( totalCurrentSum > 0 ? parseFloat( ((cumTotalActual[i] / totalCurrentSum) * 100).toFixed(2), ) : 0, ); totalForecastPct.push(null); } else { totalActualPct.push(null); cumRemainingFromReport += weekTotalRemaining[i]; const forecastAbs = actualAtReportWeek + cumRemainingFromReport; totalForecastPct.push( totalCurrentSum > 0 ? parseFloat(((forecastAbs / totalCurrentSum) * 100).toFixed(2)) : 0, ); } } const categories = sortedResourceIds.map((rid) => { const totals = resourceTotals.get(rid); const periodPlanArr = resourcePeriodPlan.get(rid) || []; const periodActualArr = resourcePeriodActual.get(rid) || []; const periodRemainingArr = resourcePeriodRemaining.get(rid) || []; const periodPlanPct = []; const periodActualPct = []; /** @type {(number|null)[]} */ const cumActualPct = []; /** @type {(number|null)[]} */ const cumForecastPct = []; let resCumActual = 0; for (let i = 0; i < sortedWeeks.length; i++) { const wi = sortedWeeks[i]; const planVal = periodPlanArr[i] || 0; const actualVal = periodActualArr[i] || 0; const remainingVal = periodRemainingArr[i] || 0; periodPlanPct.push( totalPlanSum > 0 ? parseFloat(((planVal / totalPlanSum) * 100).toFixed(2)) : 0, ); periodActualPct.push( totalCurrentSum > 0 ? parseFloat(((actualVal / totalCurrentSum) * 100).toFixed(2)) : 0, ); resCumActual += actualVal; if (reportWeekIdx == null || wi <= reportWeekIdx) { cumActualPct.push( totalCurrentSum > 0 ? parseFloat(((resCumActual / totalCurrentSum) * 100).toFixed(2)) : 0, ); cumForecastPct.push(null); } else { cumActualPct.push(null); cumForecastPct.push(null); } } return { name: totals.resourceName, color: "", periodPlanPct, periodActualPct, cumActualPct, cumForecastPct, totalPlan: totals.totalPlan, totalActual: totals.totalActual, totalRemaining: totals.totalRemaining, }; }); const totalPlanPct = cumTotalPlan.map((v) => totalPlanSum > 0 ? parseFloat(((v / totalPlanSum) * 100).toFixed(2)) : 0, ); const hasMixedUnits = distinctUnits.size > 1; const unitName = hasMixedUnits ? undefined : distinctUnits.values().next().value || undefined; return { xAxis, categories, totalPlanPct, totalActualPct, totalForecastPct, totalPlanSum, totalActualSum, totalForecastSum, hasMixedUnits, unitName, reportDate, reportWeekIdx, reportAxisIndex, }; } /** * Таблица физических объёмов (материальные ресурсы). * JS Data Engine material volume table data. * @param {string} databaseId * @param {Object} options * @param {import('../types.js').CodeFilterItem[]} [options.codeFilters] * @param {number[]} [options.wbsFilterIds] * @param {number[]} [options.resourceFilterIds] * @param {import('../types.js').WeekRange} [options.weekRange] * @returns {Promise<import('../types.js').VolumeTableRow[]>} */ async getMaterialVolumeTableData(databaseId, options = {}) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`Database ${databaseId} not found in DataIndexer`); } const materialIds = getMaterialResourceIds(indexes); const activities = this._resolveActivitySet( indexes, this._materialBaseOptions(indexes, options), ); /** @type {import('../types.js').VolumeTableRow[]} */ const rows = []; for (const a of activities) { const wbs = a.wbsId != null ? indexes.wbsById.get(a.wbsId) : undefined; const assignments = indexes.assignmentsByActivity.get(a.id) || []; for (const ra of assignments) { if (ra.resourceId == null || !materialIds.has(ra.resourceId)) continue; const res = indexes.resourcesById.get(ra.resourceId); const actualQty = ra.actualUnits || 0; const remainingQty = ra.remainingUnits || 0; const planQty = this._computeMaterialPlanQtyFromSpread( indexes, ra.assignmentId, ra.targetUnits || 0, ); rows.push({ activityId: a.id, activityCode: a.activityCode || "", taskName: a.taskName || "", resourceId: ra.resourceId, resourceName: res?.name || `Ресурс ${ra.resourceId}`, unitName: this._resourceUnitName(res), planQty, actualQty, remainingQty, percentComplete: planQty > 0 ? parseFloat(((actualQty / planQty) * 100).toFixed(2)) : 0, startDate: a.startDate, endDate: a.endDate, scheduleType: a.scheduleType, wbsCode: wbs?.code || "", wbsName: wbs?.name || "", wbsPath: wbs?.code || "", }); } } rows.sort((a, b) => { const actA = indexes.activitiesById.get(a.activityId); const actB = indexes.activitiesById.get(b.activityId); const wbsA = actA?.wbsId != null ? indexes.wbsById.get(actA.wbsId) : undefined; const wbsB = actB?.wbsId != null ? indexes.wbsById.get(actB.wbsId) : undefined; const seqDiff = (wbsA?.seqNum || 0) - (wbsB?.seqNum || 0); if (seqDiff !== 0) return seqDiff; if (a.activityId !== b.activityId) return a.activityId - b.activityId; return String(a.resourceName || "").localeCompare( String(b.resourceName || ""), ); }); return rows; } /** * @private * @param {DatabaseIndexes} indexes * @param {Object} options * @returns {Object} */ _materialBaseOptions(indexes, options) { // Phase 4: the implicit "Вид работ = СМР" filter is no longer injected here. // It is now a configurable level baseFilter, applied by _resolveActivitySet. return { ...options, dataDomain: "material", scheduleType: "both" }; } /** * @private * @param {import('../types.js').Resource|undefined} resource * @returns {string} */ _resourceUnitName(resource) { return resource?.unit || ""; } /** * Суммарный план по спреду назначения (actual + remaining) для столбца "Всего". * Если спред отсутствует — fallback на targetUnits из TASKRSRC. * @private * @param {DatabaseIndexes} indexes * @param {number} assignmentId * @param {number} fallbackTargetUnits * @returns {number} */ _computeMaterialPlanQtyFromSpread(indexes, assignmentId, fallbackTargetUnits) { const range = indexes.spreadIndexByAssignment.get(assignmentId); if (!range) return fallbackTargetUnits || 0; const spread = indexes.spread; let planQty = 0; for (let i = range.start; i < range.end; i++) { planQty += (spread.actualUnits[i] || 0) + (spread.remainingUnits[i] || 0); } return planQty || fallbackTargetUnits || 0; } /** * @private * @param {DatabaseIndexes} indexes * @param {number} typeId * @param {string} shortName * @returns {number|undefined} */ _resolveCodeValueIdByShortName(indexes, typeId, shortName) { for (const cv of indexes.codeValueById.values()) { if (cv.typeId === typeId && cv.shortName === shortName) { return cv.valueId; } } return undefined; } // ========================================================================= // Shared helpers // ========================================================================= /** * @private * @param {DatabaseIndexes} indexes * @returns {string} */ _getProjectDataDate(indexes) { const project = Array.from(indexes.projectsById.values())[0]; if (project?.dataDate) return project.dataDate; return new Date().toISOString().split("T")[0]; } /** * @private * @param {DatabaseIndexes} indexes * @param {number|null} reportWeekIdx * @param {Object} options * @param {'slice'|'range'} [options.calcMode] * @param {number|null} [options.dataDateWeekIdx] * @param {WeekRange} [options.weekRange] * @returns {number|null} */ _resolveEffectiveWeekIdx(indexes, reportWeekIdx, options = {}) { if (options.calcMode === "range") { const endIdx = options.weekRange?.endIdx; if (endIdx != null) return endIdx; return indexes.maxWeekIndex; } if (options.dataDateWeekIdx != null) return options.dataDateWeekIdx; return reportWeekIdx != null ? reportWeekIdx - 1 : null; } } const summaryJsQueryEngine = new SummaryJsQueryEngine(); if (typeof window !== "undefined") { window.summaryJsQueryEngine = summaryJsQueryEngine; } export { summaryJsQueryEngine, SummaryJsQueryEngine }; export default summaryJsQueryEngine;