/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/ResourceVolumeDataSource.js
1 679 строк
54 KB
Starolat Sergei
feat: модальное окно «Работы 4 уровня» для гамаков в Анализе отклонений
02 авг 2026, 12:48
02 авг 2026, 12:48
edc77bf
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ResourceVolumeDataSource - Источник данных таблицы ресурсных объёмов * @module ResourceVolumeDataSource * @version 0.1.0 * * Конкретный DataGridDataSource для отображения физических объёмов по материалам. * Строит иерархию "Тип ресурса → Ресурс → Работа" (или альтернативные группировки) * на основе MaterialVolumeItem из DataService. */ import DataGridDataSource from "./DataGridDataSource.js"; import dataService from "./DataService.js"; import { wbsTableDataProvider } from "./WbsTableDataProvider.js"; import { getLocalCPCellClass } from "./LocalCPStateService.js"; import { Copy, Settings, ListTree } from "../components/ConstantinIcons.js"; /** * @typedef {import('../types.js').TableColumn} TableColumn * @typedef {import('../types.js').TableRow} TableRow * @typedef {import('../types.js').MaterialVolumeItem} MaterialVolumeItem * @typedef {import('../types.js').WeekRange} WeekRange * @typedef {import('../types.js').Grouping} Grouping * @typedef {import('../types.js').GroupingPathSegment} GroupingPathSegment */ /** * @typedef {Object} ResourceVolumeDataSourceOptions * @property {string} [databaseId] * @property {import('./FilterStateManager.js').default} [filterState] * @property {boolean} [showCurrent] * @property {boolean} [showTarget] * @property {'resource'} [groupBy] * @property {import('../types.js').Filter[]} [baseFilters] * @property {import('../types.js').Filter[]} [privateFilters] */ /** * Узел иерархии ресурсных объёмов. * @typedef {Object} ResourceVolumeNode * @property {string} key * @property {string} displayName * @property {number} level * @property {string} [resourceType] * @property {number} [resourceId] * @property {number} [activityId] * @property {Map<string, ResourceVolumeNode>} children * @property {MaterialVolumeItem[]} items */ class ResourceVolumeDataSource extends DataGridDataSource { /** * @param {ResourceVolumeDataSourceOptions} options */ constructor({ databaseId, filterState, showCurrent = true, showTarget = false, groupBy = "resource", baseFilters = [], privateFilters = [], ignoreGlobalFilters = false, compactColumns = false, } = {}) { super({ databaseId, filterState }); /** @type {boolean} */ this.showCurrent = showCurrent; /** @type {boolean} */ this.showTarget = showTarget; /** @type {'resource'} */ this.groupBy = groupBy; /** @type {import('../types.js').Filter[]} */ this.baseFilters = baseFilters; /** @type {import('../types.js').Filter[]} */ this.privateFilters = privateFilters; /** * Игнорировать глобальные фильтры (codeFilters, wbsFilterIds, * resourceFilterIds, weekRange) из FilterStateManager — источник * работает только по baseFilters/privateFilters. Используется, * например, модалкой «Работы 4 уровня» из Анализа отклонений. * @type {boolean} */ this.ignoreGlobalFilters = ignoreGlobalFilters; /** * Компактные ширины колонок (для узких контейнеров, например модалки * HammockLevel4Modal): все колонки ужимаются, чтобы таблица помещалась * без горизонтального скролла. * @type {boolean} */ this.compactColumns = compactColumns; /** @type {boolean} */ this._hideEmptyGroups = true; /** @type {number|null} */ this._selectedLevel = null; /** @type {MaterialVolumeItem[]} */ this._materialItems = []; /** @type {WeekRange|null} */ this._weekRange = null; /** @type {string|null} */ this._selectedWeekEnd = null; /** @type {Grouping[]} Стек активных группировок из GroupingPanel */ this._groupings = []; /** @type {{assignmentsByAct: Map, assignmentsByActType: Map, valuesById: Map}|null} Кэш данных кодов для группировки */ this._groupingCodeData = null; /** @type {{assignmentsByAct: Map}|null} Кэш данных материальных ресурсов для группировки */ this._groupingResourceData = null; /** @type {boolean} */ this._loadInProgress = false; /** @type {boolean} */ this._pendingReload = false; /** @type {import('../types.js').LocalCPResult|null} Последний результат анализа ЛКП (COMP-033), восстанавливается после refresh */ this._localCPResult = null; /** @type {Set<string>|null} id листовых строк, проходящих фильтры заголовков DataGrid (null = фильтров нет) */ this._visibleRowFilterIds = null; /** @type {string} Ключ текущего набора видимых строк (защита от цикла setRows→filter-change→rebuild) */ this._visibleRowFilterKey = ""; this._boundHandleFilterStateChange = this._handleFilterStateChange.bind(this); this._boundHandleDatabaseReady = this._handleDatabaseReady.bind(this); this._boundHandleDatabaseChanged = this._handleDatabaseChanged.bind(this); this._boundHandleDataDateChange = this._handleDataDateChange.bind(this); this._boundHandleWeekSelect = this._handleWeekSelect.bind(this); this._boundHandleWbsFilterChange = this._handleWbsFilterChange.bind(this); this._boundHandleGroupingChange = this._handleGroupingChange.bind(this); } // ==================== Lifecycle ==================== attach() { if (this._attached) return; super.attach(); document.addEventListener("filter-state-change", this._boundHandleFilterStateChange); document.addEventListener("database-ready", this._boundHandleDatabaseReady); document.addEventListener("database-changed", this._boundHandleDatabaseChanged); document.addEventListener("data-date-change", this._boundHandleDataDateChange); document.addEventListener("week-select", this._boundHandleWeekSelect); document.addEventListener("wbs-filter-change", this._boundHandleWbsFilterChange); document.addEventListener("grouping-change", this._boundHandleGroupingChange); if (this.filterState) { const state = this.filterState.getState(); this.showCurrent = state.showCurrent; this.showTarget = state.showTarget; this._weekRange = this.filterState.getEffectiveWeekRange(); this._groupings = state.groupings || []; } } detach() { if (!this._attached) return; document.removeEventListener("filter-state-change", this._boundHandleFilterStateChange); document.removeEventListener("database-ready", this._boundHandleDatabaseReady); document.removeEventListener("database-changed", this._boundHandleDatabaseChanged); document.removeEventListener("data-date-change", this._boundHandleDataDateChange); document.removeEventListener("week-select", this._boundHandleWeekSelect); document.removeEventListener("wbs-filter-change", this._boundHandleWbsFilterChange); document.removeEventListener("grouping-change", this._boundHandleGroupingChange); super.detach(); } // ==================== Public API ==================== /** * Загрузить данные и построить иерархию. * @returns {Promise<void>} */ async refresh() { if (!this.databaseId) { this._setRows([]); return; } if (this._loadInProgress) { this._pendingReload = true; return; } this._loadInProgress = true; this._pendingReload = false; this._setLoading(true); try { if (!dataService.isInitialized) { throw new Error("DataService not initialized"); } if (this.filterState && !this.ignoreGlobalFilters) { this._weekRange = this.filterState.getEffectiveWeekRange(); } else if (this.ignoreGlobalFilters) { this._weekRange = null; } const scheduleType = this._getScheduleTypeForApi(); const ignoreGlobal = this.ignoreGlobalFilters; const codeFilters = this.filterState && !ignoreGlobal ? this.filterState.getEffectiveCodeFilters() : []; const wbsFilterIds = this.filterState && !ignoreGlobal ? this.filterState.getEffectiveWbsFilterIds() : []; const resourceFilterIds = this.filterState && !ignoreGlobal ? this.filterState.getEffectiveResourceFilterIds() : []; this._materialItems = await dataService.getMaterialVolumes(this.databaseId, { scheduleType, codeFilters, wbsFilterIds, resourceFilterIds, weekRange: this._weekRange, groupBy: "activity", baseFilters: this.baseFilters, privateFilters: this.privateFilters, ignoreGlobalFilters: ignoreGlobal, }); await this._applyCumulativeMaterialVolumes(); this._applyTargetPlanTotals(); // COMP-033: восстанавливаем оверлей ЛКП на свежих items this._applyLocalCPToItems(); if ((this._groupings || []).some((g) => g.enabled)) { try { await this._loadGroupingData(this.databaseId); } catch (e) { console.warn("[ResourceVolumeDataSource] Failed to load grouping data on refresh:", e); } } this._setColumns(this._getColumns()); this._setToolbarItems(this.getToolbarConfig()); this._rebuildHierarchy(); this._emitDataLoaded(); } catch (error) { console.error("[ResourceVolumeDataSource] refresh failed:", error); this._setError(error instanceof Error ? error.message : String(error)); } finally { this._loadInProgress = false; this._setLoading(false); if (this._pendingReload) { this._pendingReload = false; await this.refresh(); } } } /** * Установить фильтр видимых строк (от фильтров заголовков DataGrid). * Строки остаются в иерархии (видимостью управляет DataGrid), а итоговые * показатели групп пересчитываются только по видимым работам * (_calculateNodeMetrics учитывает _visibleRowFilterIds). * @param {string[]|null} leafRowIds - id листовых строк (null = фильтров нет) */ setVisibleRowFilter(leafRowIds) { const key = leafRowIds ? [...leafRowIds].sort().join("|") : ""; if (this._visibleRowFilterKey === key) return; this._visibleRowFilterKey = key; this._visibleRowFilterIds = leafRowIds ? new Set(leafRowIds) : null; if (this._materialItems.length > 0) { this._rebuildHierarchy(); } } setDatabaseId(databaseId) { if (this.databaseId === databaseId) return; this.databaseId = databaseId; this._expandedRowIds.clear(); this._selectedLevel = null; this._groupingCodeData = null; this._groupingResourceData = null; if (this._attached) { this.refresh(); } } /** * Установить базовые фильтры уровня. * @param {import('../types.js').Filter[]} filters */ setBaseFilters(filters) { const next = filters || []; if (JSON.stringify(this.baseFilters) === JSON.stringify(next)) return; this.baseFilters = next; if (this._attached) { this.refresh(); } } /** * Установить приватные фильтры виджета. * @param {import('../types.js').Filter[]} filters */ setPrivateFilters(filters) { const next = filters || []; if (JSON.stringify(this.privateFilters) === JSON.stringify(next)) return; this.privateFilters = next; if (this._attached) { this.refresh(); } } /** * Переключить группировку. * @param {'resource'} groupBy */ setGroupBy(groupBy) { if (this.groupBy === groupBy) return; this.groupBy = groupBy; this._selectedLevel = null; this._expandedRowIds.clear(); this._setToolbarItems(this.getToolbarConfig()); this._rebuildHierarchy(); this._emitGroupByChange(); } /** * Переключить отображение текущего графика. * @param {boolean} visible */ setShowCurrent(visible) { if (this.showCurrent === visible) return; this.showCurrent = visible; if (this.filterState) { this.filterState.setScheduleMode({ showCurrent: visible, showTarget: this.showTarget }); } this._setToolbarItems(this.getToolbarConfig()); this._emitScheduleModeChange(); this.refresh(); } /** * Переключить отображение целевого графика. * @param {boolean} visible */ setShowTarget(visible) { if (this.showTarget === visible) return; this.showTarget = visible; if (this.filterState) { this.filterState.setScheduleMode({ showCurrent: this.showCurrent, showTarget: visible }); } this._setToolbarItems(this.getToolbarConfig()); this._emitScheduleModeChange(); this.refresh(); } /** * Установить скрытие пустых групп. * @param {boolean} value */ setHideEmptyGroups(value) { if (this._hideEmptyGroups === value) return; this._hideEmptyGroups = value; this._setToolbarItems(this.getToolbarConfig()); this._rebuildHierarchy(); } /** * Компактные ширины колонок для узких контейнеров (сумма ≈1130px * вместо ≈1370px — помещается в модалку 90vw/1200px без скролла). * @type {Record<string, number>} * @private */ static _COMPACT_COLUMN_WIDTHS = { rowNumber: 40, activityCode: 70, name: 220, unit: 40, percentComplete: 70, planQty: 70, planTargetQty: 70, cumulativeCurrentQty: 80, cumulativeTargetQty: 80, cumulativeDeltaQty: 70, remainingQty: 70, startDate: 85, endDate: 85, localFloat: 80, }; /** * @returns {TableColumn[]} * @private */ _getColumns() { const columns = [ { id: "rowNumber", header: "№", field: "rowNumber", type: "number", width: 50, visible: true, sortable: false, filterable: false, }, { id: "activityCode", header: "ID", field: "activityCode", type: "text", width: 80, visible: true, sortable: true, filterable: true, }, { id: "name", header: "Название", field: "displayName", type: "text", width: 300, visible: true, sortable: true, filterable: true, isTreeColumn: true, }, { id: "unit", header: "Ед.", field: "unitName", type: "text", width: 50, visible: true, sortable: true, filterable: true, }, { id: "percentComplete", header: "Прогресс", field: "percentComplete", type: "percent", width: 90, visible: true, sortable: true, filterable: true, formatter: (value) => value != null ? `${Number(value).toFixed(2)}%` : "", }, { id: "planQty", header: "ВСЕГО ТГ", field: "planQty", type: "number", width: 80, visible: true, sortable: true, filterable: true, formatter: (value) => value != null ? Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "", }, { id: "planTargetQty", header: "ВСЕГО ЦП", field: "planTargetQty", type: "number", width: 80, visible: true, sortable: true, filterable: true, // null = нет соответствующей работы в ЦП → прочерк; // undefined (группы со смешанными единицами) → пусто formatter: (value) => value === null ? "—" : value != null ? Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "", }, { id: "cumulativeCurrentQty", header: "НАК.ТГ ФО", field: "cumulativeCurrentQty", type: "number", width: 90, visible: true, sortable: true, filterable: true, formatter: (value) => value != null ? Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "", }, { id: "cumulativeTargetQty", header: "НАК.ЦП ФО", field: "cumulativeTargetQty", type: "number", width: 90, visible: true, sortable: true, filterable: true, formatter: (value) => value != null ? Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "", }, { id: "cumulativeDeltaQty", header: "Дельта", field: "cumulativeDeltaQty", type: "number", width: 90, visible: true, sortable: true, filterable: true, cellStyle: (data) => Number(data.cumulativeDeltaQty) < 0 ? "color: var(--color-typo-alert, var(--error-color, #eb5757)); font-weight: bold;" : "", formatter: (value) => value != null ? Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "", }, { id: "remainingQty", header: "Остаток", field: "remainingQty", type: "number", width: 80, visible: true, sortable: true, filterable: true, formatter: (value) => value != null ? Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 2 }) : "", }, { id: "startDate", header: "Начало", field: "startDate", type: "date", width: 100, visible: true, sortable: true, filterable: true, formatter: (value) => this._formatDate(value), }, { id: "endDate", header: "Окончание", field: "endDate", type: "date", width: 100, visible: true, sortable: true, filterable: true, formatter: (value) => this._formatDate(value), }, { // COMP-033: локальный резерв до целевой вехи (LocalCPStateService). // Лист = назначение ресурса: значение дублируется по ресурсам работы // (принято, спека F.4); у групповых строк — пусто. id: "localFloat", header: "Резерв до вехи", field: "localFloat", type: "number", width: 90, visible: true, sortable: true, filterable: true, formatter: (value) => (value != null ? String(Number(value)) : ""), cellClass: (data) => { const cls = getLocalCPCellClass(data?.localFloatCriticality); const negative = typeof data?.localFloat === "number" && data.localFloat < 0 ? " localcp-cell-negative" : ""; return cls + negative; }, }, ]; if (this.compactColumns) { const widths = ResourceVolumeDataSource._COMPACT_COLUMN_WIDTHS; for (const col of columns) { if (widths[col.id] != null) col.width = widths[col.id]; } } return columns; } /** * Переключить компактные ширины колонок (без горизонтального скролла * в узких контейнерах). * @param {boolean} value */ setCompactColumns(value) { const next = Boolean(value); if (this.compactColumns === next) return; this.compactColumns = next; if (this._materialItems.length > 0) { this._setColumns(this._getColumns()); } } /** * Дополнить material items накопительными объёмами ТГ/ЦП по неделям. * Использует weekRange если активен, иначе dataDate/timeline. * @private */ async _applyCumulativeMaterialVolumes() { if (!this.databaseId || this._materialItems.length === 0) return; const hasSpreadData = await wbsTableDataProvider.hasSpreadData(this.databaseId); if (!hasSpreadData) return; let currentMap = new Map(); let targetMap = new Map(); const isRangeActive = this._weekRange && (this._weekRange.startIdx != null || this._weekRange.endIdx != null); if (isRangeActive) { ({ current: currentMap, target: targetMap } = await wbsTableDataProvider.getMaterialVolumesByWeekRange( this._weekRange.startIdx, this._weekRange.endIdx, this.databaseId, )); } else { const effectiveDataDate = this.filterState?.getEffectiveDataDate(); const weekEnd = effectiveDataDate?.weekEnd; if (!weekEnd) return; ({ current: currentMap, target: targetMap } = await wbsTableDataProvider.getCumulativeMaterialVolumesByWeek( weekEnd, this.databaseId, )); } for (const item of this._materialItems) { const key = item.activityCode; if (!key) continue; const currentEntry = currentMap.get(key); const targetEntry = targetMap.get(key); item.cumulativeCurrentQty = currentEntry?.planQty || 0; item.cumulativeTargetQty = targetEntry?.planQty || 0; // Нормализация float-шума: |Δ| < 1e-6 считаем нулём, иначе при // суммировании spread-значений возникает «-0», проходящий фильтр < 0 const delta = item.cumulativeCurrentQty - item.cumulativeTargetQty; item.cumulativeDeltaQty = Math.abs(delta) < 1e-6 ? 0 : delta; } } /** * Применить результат анализа ЛКП к колонке «Резерв до вехи» (COMP-033, спека F.4). * Проставляет localFloat/localFloatCriticality на листовых items по activityId * (лист = назначение ресурса: одна работа встречается на нескольких строках — * значение дублируется, это принято). Значения только у строк текущего графика * (scheduleType 'current'); ЦП — пусто. Групповые строки не трогаются * (агрегация min-резерва по группе — v1.1, вне scope). * Результат запоминается и восстанавливается после refresh(). * @param {import('../types.js').LocalCPResult|null} result - null → очистка полей */ applyLocalCPResult(result) { this._localCPResult = result ?? null; this._applyLocalCPToItems(); // Листовые строки уже построены (row.data — копия item): патчим in-place, // чтобы обновить таблицу без полного _rebuildHierarchy. for (const row of this.getRows() || []) { if (row.type !== "activity") continue; this._applyLocalCPToRecord(row.data, row.activityId ?? row.data?.activityId); } this._notifyDataChanged(); } /** * Инжект значений ЛКП во все листовые items (COMP-033). * @private */ _applyLocalCPToItems() { for (const item of this._materialItems) { this._applyLocalCPToRecord(item, item?.activityId); } } /** * Инжект значений ЛКП в один объект (item или row.data листовой строки). * @private * @param {Object|null|undefined} record * @param {number|null|undefined} activityId */ _applyLocalCPToRecord(record, activityId) { if (!record) return; const result = this._localCPResult; const r = result && record.scheduleType === "current" ? result.results.get(Number(activityId)) : null; record.localFloat = r ? r.totalFloatLocal : null; record.localFloatCriticality = r ? r.criticality : null; } /** * Проставить planTargetQty (план по ЦП) на каждый item. * getMaterialVolumes возвращает строки обоих графиков (engine форсирует * scheduleType='both'), поэтому карта планов ЦП строится из target-строк * уже загруженной выборки. Сверка ТГ↔ЦП — по activityCode + resourceId * (листовая строка = назначение ресурса на работу). * Если для работы ТГ нет соответствующей работы в ЦП — planTargetQty = null * (в таблице отображается прочерк, а не 0). * @private */ _applyTargetPlanTotals() { if (!this._materialItems || this._materialItems.length === 0) return; /** @type {Map<string, number>} */ const targetPlanByKey = new Map(); for (const item of this._materialItems) { if (item.scheduleType !== "target") continue; const key = `${item.activityCode}|${item.resourceId}`; targetPlanByKey.set( key, (targetPlanByKey.get(key) || 0) + (Number(item.planQty) || 0), ); } for (const item of this._materialItems) { const key = `${item.activityCode}|${item.resourceId}`; item.planTargetQty = item.scheduleType === "target" ? Number(item.planQty) || 0 : targetPlanByKey.get(key) ?? null; } } /** * Получить конфигурацию toolbar. * @returns {import('./DataGridDataSource.js').ToolbarItem[]} */ getToolbarConfig() { const hasData = this._materialItems.length > 0; const maxLevel = this._getMaxLevel(); return [ { id: "schedule-mode", type: "toggle-group", title: "Текущий / Целевой график", options: [ { id: "current", label: "ТГ", active: this.showCurrent }, { id: "target", label: "ЦП", active: this.showTarget }, ], onToggle: (optionId) => { if (optionId === "current") { this.setShowCurrent(!this.showCurrent); } else if (optionId === "target") { this.setShowTarget(!this.showTarget); } }, }, { id: "sep-schedule", type: "separator" }, { id: "rv-levels", type: "level-buttons", title: "Свернуть до уровня", maxLevel, selectedLevel: this._selectedLevel, disabled: !hasData || maxLevel <= 0, onLevelClick: (level) => this.collapseToLevel(level), }, { id: "sep-levels", type: "separator" }, { id: "btn-copy", type: "icon-button", action: "copy", label: "Копировать", title: "Копировать для Excel", disabled: false, icon: Copy, onClick: () => {}, }, { id: "sep-grouping", type: "separator" }, { id: "rv-grouping", type: "grouping", title: "Группировка", disabled: !hasData, icon: ListTree, badge: (this._groupings || []).filter((g) => g.enabled).length, }, { id: "btn-settings", type: "icon-button", action: "settings", label: "Настройки", title: "Настройки таблицы", disabled: false, icon: Settings, settings: [ { id: "hide-empty-groups", label: "Скрывать пустые группы", checked: this._hideEmptyGroups, onChange: (checked) => this.setHideEmptyGroups(checked), }, ], }, ]; } // ==================== Event handlers ==================== /** * @param {CustomEvent} e * @private */ _handleFilterStateChange(e) { const { action, state } = e.detail || {}; if (action === "week-filter-change") { this._weekRange = state?.weekRange || { startIdx: null, endIdx: null }; this.refresh(); return; } if (action === "schedule-mode-change") { const { showCurrent, showTarget } = state || {}; if (typeof showCurrent === "boolean" && typeof showTarget === "boolean") { if (this.showCurrent !== showCurrent || this.showTarget !== showTarget) { this.showCurrent = showCurrent; this.showTarget = showTarget; this._emitScheduleModeChange(); this.refresh(); } } return; } const reloadActions = [ "code-filter-set", "code-filter-remove", "code-filter-toggle", "code-filter-clear", "code-filter-replace", "wbs-filter-change", "resource-filter-change", ]; if (reloadActions.includes(action)) { this.refresh(); } } /** * @param {CustomEvent} e * @private */ _handleDatabaseReady(e) { const dbId = e.detail?.databaseId; if (dbId) this.setDatabaseId(dbId); } /** * @param {CustomEvent} e * @private */ _handleDatabaseChanged(e) { const dbId = e.detail?.databaseId; if (dbId) this.setDatabaseId(dbId); } /** * @param {CustomEvent} e * @private */ _handleDataDateChange(e) { const { dataDate } = e.detail || {}; if (!dataDate || !this.databaseId) return; if (dataDate.mode === "range") { this._weekRange = { startIdx: dataDate.startIdx ?? null, endIdx: dataDate.endIdx ?? null, }; } else if (dataDate.mode === "timeline") { this._weekRange = { startIdx: null, endIdx: null }; } this.refresh(); } /** * @param {CustomEvent} e * @private */ _handleWeekSelect(e) { const weekEnd = e.detail?.weekEnd; if (!weekEnd || !this.databaseId) return; this._weekRange = { startIdx: null, endIdx: null }; this.refresh(); } /** * @param {CustomEvent} e * @private */ _handleWbsFilterChange(e) { this.refresh(); } // ==================== Grouping (GroupingPanel integration) ==================== /** * Сравнить два стека группировок (идемпотентность обработчика). * @param {Grouping[]} a * @param {Grouping[]} b * @returns {boolean} * @private */ _groupingsEqual(a, b) { const left = a || []; const right = b || []; if (left.length !== right.length) return false; for (let i = 0; i < left.length; i++) { if ( (left[i].kind || "code") !== (right[i].kind || "code") || left[i].field !== right[i].field || left[i].typeId !== right[i].typeId || left[i].enabled !== right[i].enabled || left[i].level !== right[i].level ) { return false; } } return true; } /** * Обработчик DOM-события `grouping-change` от GroupingPanel / FilterStateManager. * @param {CustomEvent} event * @private */ async _handleGroupingChange(event) { const detail = event.detail || {}; await this._applyGroupings(detail.groupings || []); } /** * Публичный вход для SSOT-догона из виджета (без события). * @param {Grouping[]} groupings */ async applyGroupingsFromState(groupings) { await this._applyGroupings(groupings || []); } /** * Применить новый стек группировок: догрузить code/resource-данные и перестроить иерархию. * @param {Grouping[]} newGroupings * @private */ async _applyGroupings(newGroupings) { if (this._groupingsEqual(this._groupings, newGroupings)) return; this._groupings = newGroupings || []; const hasGroupings = this._groupings.some((g) => g.enabled); if (hasGroupings) { try { await this._loadGroupingData(this.databaseId); } catch (e) { console.warn("[ResourceVolumeDataSource] Failed to load grouping data:", e); } } else { // пустой стек (legacy) или плоский список (все уровни выключены) this._groupingCodeData = null; this._groupingResourceData = null; } // Любое реальное изменение стека перестраивает таблицу // (включая переходы дефолт ↔ flat, где enabled-набор не меняется). this._selectedLevel = null; this._expandedRowIds.clear(); this._rebuildHierarchy(); this._setToolbarItems(this.getToolbarConfig()); this._emitLevelsChange(); } /** * Загрузить данные для активных группировок (коды и/или материальные ресурсы). * @param {string} [databaseId] * @private */ async _loadGroupingData(databaseId) { const id = databaseId || this.databaseId; if (!id) return; const enabledGroupings = (this._groupings || []).filter((g) => g.enabled); const enabledTypeIds = enabledGroupings .filter((g) => g.kind !== "resource") .map((g) => g.typeId) .filter(Boolean); const hasResourceGrouping = enabledGroupings.some((g) => g.kind === "resource"); if (enabledTypeIds.length === 0 && !hasResourceGrouping) { this._groupingCodeData = null; this._groupingResourceData = null; return; } if (enabledTypeIds.length > 0) { const codeData = await wbsTableDataProvider.getGroupingCodeData(id, enabledTypeIds); this._groupingCodeData = { assignmentsByAct: codeData.assignmentsByAct, assignmentsByActType: codeData.assignmentsByActType, valuesById: codeData.valuesById, }; } else { this._groupingCodeData = null; } if (hasResourceGrouping) { const materialData = await wbsTableDataProvider.getGroupingMaterialData(id); this._groupingResourceData = { assignmentsByAct: materialData.assignmentsByAct }; } else { this._groupingResourceData = null; } } // ==================== Hierarchy builders ==================== /** * Перестроить строки таблицы из загруженных данных. * @private */ _rebuildHierarchy() { const items = this._getFilteredItems(); const rows = []; const root = { children: new Map(), items: [] }; const allGroupings = this._groupings || []; for (const item of items) { const paths = this._getGroupingPaths(item, allGroupings); for (const path of paths) { let node = root; for (let i = 0; i < path.length; i++) { const segment = path[i]; if (!node.children.has(segment.key)) { node.children.set(segment.key, { key: segment.key, displayName: segment.displayName, level: i, codeValueId: segment.codeValueId ?? null, resourceId: segment.resourceId ?? null, color: segment.color || "", children: new Map(), items: [], }); } node = node.children.get(segment.key); } node.items.push(item); } } this._calculateNodeMetrics(root); this._pruneEmptyNodes(root); // Auto-expand root and first level by default (only when no explicit level is selected) const projectId = "rv-root-project"; if (this._selectedLevel === null && this._expandedRowIds.size === 0 && root.children.size > 0) { this._expandedRowIds.add(projectId); for (const [key] of root.children) { this._expandedRowIds.add(this._makeNodeId([key])); } } const projectName = "Физические объёмы"; const rootMetrics = root._metrics || { planQty: 0, planTargetQty: 0, remainingQty: 0, cumulativeCurrentQty: 0, cumulativeTargetQty: 0, cumulativeDeltaQty: 0, percentComplete: 0, activityCount: 0, }; rows.push({ type: "group", id: projectId, level: 0, expanded: this._selectedLevel === null || this._selectedLevel >= 1, hasChildren: root.children.size > 0, data: { name: projectName, displayName: `${projectName} (${rootMetrics.activityCount})`, planQty: rootMetrics.planQty, planTargetQty: rootMetrics.planTargetQty, remainingQty: rootMetrics.remainingQty, cumulativeCurrentQty: rootMetrics.cumulativeCurrentQty, cumulativeTargetQty: rootMetrics.cumulativeTargetQty, cumulativeDeltaQty: rootMetrics.cumulativeDeltaQty, percentComplete: rootMetrics.percentComplete, activityCount: rootMetrics.activityCount, unitName: rootMetrics.unitName, startDate: rootMetrics.startDate, endDate: rootMetrics.endDate, }, displayName: `${projectName} (${rootMetrics.activityCount})`, sortOrder: 0, }); const traverse = (node, visualLevel, path, forceCollapsed = false, parentId = null) => { const sortedChildren = Array.from(node.children.values()).sort((a, b) => { const aTail = a.key === "no-code" || a.key === "no-resource"; const bTail = b.key === "no-code" || b.key === "no-resource"; if (aTail !== bTail) return aTail ? 1 : -1; return a.displayName.localeCompare(b.displayName, "ru-RU"); }); for (const child of sortedChildren) { const childPath = [...path, child.key]; const nodeId = this._makeNodeId(childPath); const childVisualLevel = visualLevel + 1; const isCollapsed = forceCollapsed || (this._selectedLevel !== null && childVisualLevel >= this._selectedLevel); const hasSubGroups = child.children.size > 0; const hasActivities = child.items.length > 0; const hasChildren = hasSubGroups || hasActivities; const metrics = child._metrics || { planQty: 0, planTargetQty: 0, remainingQty: 0, cumulativeCurrentQty: 0, cumulativeTargetQty: 0, cumulativeDeltaQty: 0, percentComplete: 0, activityCount: 0, }; rows.push({ type: "group", id: nodeId, level: childVisualLevel, expanded: !isCollapsed, hasChildren, parentId: parentId || null, data: { name: child.displayName, displayName: `${child.displayName} (${metrics.activityCount})`, planQty: metrics.planQty, planTargetQty: metrics.planTargetQty, remainingQty: metrics.remainingQty, cumulativeCurrentQty: metrics.cumulativeCurrentQty, cumulativeTargetQty: metrics.cumulativeTargetQty, cumulativeDeltaQty: metrics.cumulativeDeltaQty, percentComplete: metrics.percentComplete, activityCount: metrics.activityCount, resourceType: child.resourceType, resourceId: child.resourceId, codeValueId: child.codeValueId, color: child.color, unitName: metrics.unitName, startDate: metrics.startDate, endDate: metrics.endDate, }, displayName: `${child.displayName} (${metrics.activityCount})`, sortOrder: rows.length, }); if (hasSubGroups) { traverse(child, childVisualLevel, childPath, false, nodeId); } if (hasActivities) { const sortedItems = [...child.items].sort((a, b) => (a.activityCode || "").localeCompare(b.activityCode || "") ); for (const item of sortedItems) { rows.push(this._makeActivityRow(item, childVisualLevel + 1, nodeId)); } } } }; traverse(root, 0, [], false, projectId); // Плоский список: работы без группировок лежат прямо в root.items if (root.items.length > 0) { const sortedRootItems = [...root.items].sort((a, b) => (a.activityCode || "").localeCompare(b.activityCode || ""), ); for (const item of sortedRootItems) { rows.push(this._makeActivityRow(item, 1, projectId)); } } this._syncExpandedState(rows); this._setRows(rows); } /** * Синхронизировать `_expandedRowIds` с флагами `expanded` сгенерированных строк. * DataGrid использует собственный `expandedRowIds`, поэтому источник данных * должен явно обновлять этот набор при перестроении иерархии. * @param {TableRow[]} rows * @private */ _syncExpandedState(rows) { this._expandedRowIds.clear(); for (const row of rows) { if (row.expanded) { this._expandedRowIds.add(row.id); } } } /** * Удалить узлы без activity-потомков, если включено скрытие пустых групп. * @param {Object} node * @private */ _pruneEmptyNodes(node) { if (!this._hideEmptyGroups) return; for (const [key, child] of node.children) { this._pruneEmptyNodes(child); const hasActivities = (child.items?.length || 0) > 0; const hasChildrenWithActivities = child.children.size > 0; if (!hasActivities && !hasChildrenWithActivities) { node.children.delete(key); } } } /** * Построить один или несколько путей группировки для MaterialVolumeItem. * Для MaterialVolumeItem практически всегда ровно один путь (один resourceId, * один сегмент на typeId); pivot оставлен для корректности и будущего. * При пустом стеке groupings — fallback «ресурс» (прежнее поведение). * Если стек не пуст, но все уровни выключены — плоский список (пустой путь). * @param {MaterialVolumeItem} item * @param {Grouping[]} groupings - полный стек группировок (не только enabled) * @returns {GroupingPathSegment[][]} * @private */ _getGroupingPaths(item, groupings) { const all = groupings || []; const enabled = all.filter((g) => g.enabled); if (all.length === 0) { // legacy default «ресурс» (пустой стек) return [ [ { key: `res-${item.resourceId}`, displayName: item.resourceName || `Ресурс ${item.resourceId}`, resourceId: item.resourceId, }, ], ]; } if (enabled.length === 0) { // все уровни выключены пользователем → плоский список (без групп) return [[]]; } const activityId = item.id ?? item.activityId; const scheduleType = item.scheduleType || "current"; const baseKey = `${activityId}|${scheduleType}`; // При ignoreGlobalFilters (модалка «Работы 4 уровня») глобальный // ресурсный фильтр приложения не должен влиять на группировку модалки — // иначе все строки сваливались бы в «Без ресурса». const resourceFilterIds = this.filterState && !this.ignoreGlobalFilters ? this.filterState.getEffectiveResourceFilterIds() : []; const allowedResourceIds = resourceFilterIds.length > 0 ? new Set(resourceFilterIds) : null; /** @type {GroupingPathSegment[][]} */ let paths = [[]]; for (const g of enabled) { if (g.kind === "resource") { /** @type {Array<{resourceId: number, resourceName: string}>} */ let resources = []; if (item.resourceId != null) { resources = [ { resourceId: item.resourceId, resourceName: item.resourceName || "Ресурс" }, ]; } else { resources = this._groupingResourceData?.assignmentsByAct.get(baseKey) || []; } const filtered = allowedResourceIds ? resources.filter((r) => allowedResourceIds.has(r.resourceId)) : resources; if (filtered.length === 0) { paths = paths.map((path) => [ ...path, { key: "no-resource", displayName: "Без ресурса", resourceId: null }, ]); } else { const next = []; for (const path of paths) { for (const r of filtered) { next.push([ ...path, { key: `res-${r.resourceId}`, displayName: r.resourceName, resourceId: r.resourceId, }, ]); } } paths = next; } } else { const a = this._groupingCodeData?.assignmentsByActType.get(`${baseKey}|${g.typeId}`); /** @type {GroupingPathSegment} */ let segment; if (!a) { segment = { key: "no-code", displayName: "Без назначения", codeValueId: null }; } else { const val = this._groupingCodeData?.valuesById.get(a.codeValueId); const codeName = val?.shortName || ""; const description = val?.description || ""; let displayName = ""; if (codeName && description && codeName !== description) { displayName = `${codeName} — ${description}`; } else if (codeName) { displayName = codeName; } else if (description) { displayName = description; } else { displayName = "Без названия"; } segment = { key: String(a.codeValueId), displayName, codeValueId: a.codeValueId, color: val?.color, }; } paths = paths.map((path) => [...path, segment]); } } return paths; } /** * @param {MaterialVolumeItem} item * @param {number} level * @param {string} [parentId] * @returns {TableRow} * @private */ _makeActivityRow(item, level, parentId) { const displayName = `${item.activityCode || ""} ${item.taskName || ""}`.trim(); return { type: "activity", id: `rv-act-${item.activityId}-${item.resourceId}`, activityId: item.activityId, level, expanded: false, hasChildren: false, data: { ...item, displayName, }, displayName, sortOrder: 0, parentId: parentId || null, }; } /** * @param {Object} node * @private */ _calculateNodeMetrics(node) { let planQty = 0; let planTargetQty = 0; let remainingQty = 0; let cumulativeCurrentQty = 0; let cumulativeTargetQty = 0; let activityCount = 0; let startDate = null; let endDate = null; /** @type {Set<string>} Единицы измерения в поддереве (для защиты от суммирования тонн с метрами) */ const units = new Set(); const seenActivityIds = new Set(); const visibleIds = this._visibleRowFilterIds; for (const item of node.items || []) { // При активных фильтрах заголовков итоги считаются только по видимым // работам; сами строки остаются в иерархии (видимостью управляет DataGrid). if ( visibleIds && !visibleIds.has(`rv-act-${item.activityId}-${item.resourceId}`) ) { continue; } planQty += Number(item.planQty) || 0; planTargetQty += Number(item.planTargetQty) || 0; remainingQty += Number(item.remainingQty) || 0; cumulativeCurrentQty += Number(item.cumulativeCurrentQty) || 0; cumulativeTargetQty += Number(item.cumulativeTargetQty) || 0; if (item.unitName) units.add(item.unitName); if (!seenActivityIds.has(item.activityId)) { seenActivityIds.add(item.activityId); activityCount++; } if (item.startDate) { if (!startDate || item.startDate < startDate) startDate = item.startDate; } if (item.endDate) { if (!endDate || item.endDate > endDate) endDate = item.endDate; } } for (const child of node.children?.values() || []) { const childMetrics = this._calculateNodeMetrics(child); planQty += childMetrics.planQty ?? 0; planTargetQty += childMetrics.planTargetQty ?? 0; remainingQty += childMetrics.remainingQty ?? 0; cumulativeCurrentQty += childMetrics.cumulativeCurrentQty ?? 0; cumulativeTargetQty += childMetrics.cumulativeTargetQty ?? 0; activityCount += childMetrics.activityCount; if (childMetrics._units) { for (const u of childMetrics._units) units.add(u); } if (childMetrics.startDate) { if (!startDate || childMetrics.startDate < startDate) startDate = childMetrics.startDate; } if (childMetrics.endDate) { if (!endDate || childMetrics.endDate > endDate) endDate = childMetrics.endDate; } } const hasMixedUnits = units.size > 1; const unitName = units.size === 1 ? [...units][0] : null; if (hasMixedUnits) { node._metrics = { planQty: null, // undefined (не null): на группах со смешанными единицами «ВСЕГО ЦП» // остаётся пустым — null зарезервирован под «нет работы в ЦП» (прочерк) planTargetQty: undefined, remainingQty: null, cumulativeCurrentQty: null, cumulativeTargetQty: null, cumulativeDeltaQty: null, percentComplete: null, activityCount, startDate, endDate, unitName, _units: units, }; return node._metrics; } const percentComplete = planQty > 0 ? Math.round((cumulativeCurrentQty / planQty) * 100 * 100) / 100 : 0; // Нормализация float-шума (см. _applyCumulativeMaterialVolumes) const delta = cumulativeCurrentQty - cumulativeTargetQty; node._metrics = { planQty, planTargetQty, remainingQty, cumulativeCurrentQty, cumulativeTargetQty, cumulativeDeltaQty: Math.abs(delta) < 1e-6 ? 0 : delta, percentComplete, activityCount, startDate, endDate, unitName, _units: units, }; return node._metrics; } /** * @param {string[]} pathKeys * @returns {string} * @private */ _makeNodeId(pathKeys) { return `rv-${pathKeys.join("|")}`; } /** * @returns {MaterialVolumeItem[]} * @private */ _getFilteredItems() { let items = this._materialItems || []; items = items.filter((item) => { if (item.scheduleType === "current") return this.showCurrent; if (item.scheduleType === "target") return this.showTarget; return true; }); return items; } /** * @returns {'current'|'target'|'both'} * @private */ _getScheduleTypeForApi() { if (this.showCurrent && this.showTarget) return "both"; if (this.showTarget) return "target"; return "current"; } /** * @returns {number} * @private */ _getMaxLevel() { const all = this._groupings || []; const enabledCount = all.filter((g) => g.enabled).length; // проект(0) → уровни группировки(1..N) → работа(N+1) if (all.length === 0) return 2; // legacy: проект → ресурс → работа if (enabledCount === 0) return 1; // плоский список: проект → работа return enabledCount + 1; } /** * @param {string|Date|null} dateStr * @returns {string} * @private */ _formatDate(dateStr) { if (!dateStr) return ""; const date = new Date(dateStr); if (isNaN(date.getTime())) return String(dateStr); return date.toLocaleDateString("ru-RU"); } // ==================== Tree overrides ==================== /** * @override * @protected */ _onExpandedChanged() { this._setToolbarItems(this.getToolbarConfig()); } /** * @override */ expandAll() { this._selectedLevel = null; this._expandedRowIds.clear(); this._rebuildHierarchy(); const newExpanded = new Set(); for (const row of this._rows) { if (row.hasChildren && row.id !== "rv-root-project") { newExpanded.add(row.id); } } this._expandedRowIds = newExpanded; this._setToolbarItems(this.getToolbarConfig()); this._emitLevelsChange(); } /** * @override * @param {number} level */ collapseToLevel(level) { this._selectedLevel = level; this._expandedRowIds.clear(); this._rebuildHierarchy(); this._setToolbarItems(this.getToolbarConfig()); this._emitLevelsChange(); } /** * @override */ collapseAll() { this.collapseToLevel(0); } /** * @returns {number} */ getMaxLevel() { return this._getMaxLevel(); } /** * @returns {number|null} */ getSelectedLevel() { return this._selectedLevel; } // ==================== Emitters ==================== /** * @protected */ _emitDataLoaded() { this.dispatchEvent( new CustomEvent("data-loaded", { detail: { rowCount: this._rows.length }, bubbles: true, composed: true, }), ); } /** * @protected */ _emitScheduleModeChange() { this.dispatchEvent( new CustomEvent("schedule-mode-change", { detail: { showCurrent: this.showCurrent, showTarget: this.showTarget }, bubbles: true, composed: true, }), ); } /** * @protected */ _emitGroupByChange() { this.dispatchEvent( new CustomEvent("group-by-change", { detail: { groupBy: this.groupBy }, bubbles: true, composed: true, }), ); } /** * @protected */ _emitLevelsChange() { this.dispatchEvent( new CustomEvent("levels-change", { detail: { maxLevel: this.getMaxLevel(), selectedLevel: this.getSelectedLevel(), }, bubbles: true, composed: true, }), ); } } export default ResourceVolumeDataSource; export { ResourceVolumeDataSource };