/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/LocalCPStateService.js
395 строк
17 KB
Starolat Sergei
feat: тултип с описанием и сроками на всех строках селектора «Цель КП» (COMP-033)
30 июл 2026, 07:21
30 июл 2026, 07:21
69aa357
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview LocalCPStateService — UI-состояние анализа локального * критического пути (COMP-033, specs/widgets/LocalCPReserveOverlay.md v0.1.1). * * Обёртка над LocalCriticalPathService (SYS-024): * - хранит per-databaseId состояние {state: LocalCPUIState, result: LocalCPResult}; * - setTarget — полный расчёт (scheduleType 'current', пороги из текущего * состояния, default 0/5); * - setOptions — изменение дедлайна/порогов: смена дедлайна → полный пересчёт; * только пороги → переклассификация criticality по готовым totalFloatLocal * БЕЗ вызова computeLocalFloat (SYS-024 H.10 п.5); * - публикует document-событие 'local-cp-change' (bubbles+composed, * detail: LocalCPChangeDetail) при любом изменении; без DOM — no-op; * - подписка на 'database-removed' (invalidate) и 'database-changed' * (re-emit для новой активной БД) — только при наличии document; * - getTargetOptions — список работ/вех текущего графика для селектора цели * (без LOE/WBS-summary; вехи первыми, затем работы; по activityCode). * * Сервис обязан работать в node-окружении (нет document): все обращения * к DOM защищены проверками `typeof document !== 'undefined'`. */ import { dataIndexer, findCodeTypeByName } from "./DataIndexer.js"; import { localCriticalPathService } from "./LocalCriticalPathService.js"; /** Порог критичности по умолчанию (рабочие дни), спека F.1 п.6. */ const DEFAULT_CRITICAL_THRESHOLD = 0; /** Порог близкой зоны по умолчанию (рабочие дни), спека F.1 п.6. */ const DEFAULT_NEAR_CRITICAL_THRESHOLD = 5; /** Справочник, определяющий ключевые вехи для селектора цели ЛКП. */ const KEY_MILESTONE_CODE_TYPE = "ЦПС_Отчетность"; /** short_name кодов ключевых вех («Техзапуск», «ВехаСМР»). */ const KEY_MILESTONE_CODE_VALUES = new Set(["Техзапуск", "ВехаСМР"]); /** * Маппинг зоны критичности в CSS-класс ячейки колонки «Резерв до вехи» * (общий хелпер для WbsActivitiesTable / VolumeTableWidget / * ScheduleDeviationAnalyzer; TC-LCO-008). * @param {'critical'|'near'|'normal'|null|undefined} criticality * @returns {'localcp-cell-critical'|'localcp-cell-near'|''} */ function getLocalCPCellClass(criticality) { if (criticality === "critical") return "localcp-cell-critical"; if (criticality === "near") return "localcp-cell-near"; return ""; } class LocalCPStateService { /** * @param {import('./LocalCriticalPathService.js').LocalCriticalPathService} [cpService] - вычислитель ЛКП * @param {import('./DataIndexer.js').DataIndexer} [indexer] - поставщик индексов */ constructor(cpService = localCriticalPathService, indexer = dataIndexer) { this._cp = cpService; this._indexer = indexer; /** @type {Map<string, {state: import('../types.js').LocalCPUIState, result: import('../types.js').LocalCPResult}>} */ this._byDatabase = new Map(); /** @type {Map<string, {criticalThreshold: number, nearCriticalThreshold: number}>} пороги, заданные до выбора цели */ this._defaults = new Map(); /** @type {Map<string, import('../types.js').LocalCPTargetOption[]>} кэш списка селектора (сброс в invalidate) */ this._targetOptionsCache = new Map(); this._subscribeToDocumentEvents(); } // ========================================================================= // Public API (COMP-033, раздел E) // ========================================================================= /** * Задать цель анализа, выполнить расчёт, опубликовать local-cp-change. * Пороги берутся из текущего состояния БД (либо из сохранённых дефолтов, * либо 0/5). deadlineDate передаётся как есть: без него якорь — плановый * финиш/ограничение цели (SYS-024 H.5). * @param {Object} options * @param {string} options.databaseId * @param {number} options.targetId - activityId целевой работы/вехи * @param {string} [options.deadlineDate] - ручной дедлайн (YYYY-MM-DD) * @returns {import('../types.js').LocalCPResult|null} null — если расчёт упал с исключением (состояние не изменено) */ setTarget(options) { const { databaseId, targetId, deadlineDate } = options; const prevState = this._byDatabase.get(databaseId)?.state; const defaults = this._defaults.get(databaseId); const criticalThreshold = prevState?.criticalThreshold ?? defaults?.criticalThreshold ?? DEFAULT_CRITICAL_THRESHOLD; const nearCriticalThreshold = prevState?.nearCriticalThreshold ?? defaults?.nearCriticalThreshold ?? DEFAULT_NEAR_CRITICAL_THRESHOLD; /** @type {import('../types.js').LocalCPResult} */ let result; try { result = this._cp.computeLocalFloat({ databaseId, targetId, scheduleType: "current", deadlineDate, criticalThreshold, nearCriticalThreshold, }); } catch (err) { // Спека G: ошибка расчёта → console.error, состояние не меняется console.error("[LocalCPStateService] Ошибка расчёта ЛКП:", err); return this._byDatabase.get(databaseId)?.result ?? null; } /** @type {import('../types.js').LocalCPUIState} */ const state = { targetId, criticalThreshold, nearCriticalThreshold }; if (deadlineDate) state.deadlineDate = deadlineDate; this._byDatabase.set(databaseId, { state, result }); this._emit(databaseId); return result; } /** * Изменить дедлайн/пороги анализа. * - Изменился только порог — переклассификация criticality по готовым * totalFloatLocal, computeLocalFloat НЕ вызывается (спека F.1 п.3); * - Изменился дедлайн — полный пересчёт через computeLocalFloat; * - Цели нет — пороги сохраняются и будут применены будущим setTarget. * @param {Object} options * @param {string} options.databaseId * @param {string|null} [options.deadlineDate] - ручной дедлайн (YYYY-MM-DD); null/'' — сброс ручного дедлайна; undefined — не менять * @param {number} [options.criticalThreshold] * @param {number} [options.nearCriticalThreshold] * @returns {import('../types.js').LocalCPResult|null} */ setOptions(options) { const { databaseId } = options; const entry = this._byDatabase.get(databaseId); const defaults = this._defaults.get(databaseId); const criticalThreshold = options.criticalThreshold ?? entry?.state.criticalThreshold ?? defaults?.criticalThreshold ?? DEFAULT_CRITICAL_THRESHOLD; const nearCriticalThreshold = options.nearCriticalThreshold ?? entry?.state.nearCriticalThreshold ?? defaults?.nearCriticalThreshold ?? DEFAULT_NEAR_CRITICAL_THRESHOLD; if (!entry) { // Цель не задана — запоминаем пороги для будущего setTarget this._defaults.set(databaseId, { criticalThreshold, nearCriticalThreshold, }); this._emit(databaseId); return null; } const prevState = entry.state; // undefined — ключ не передан (не менять); null/'' — явный сброс дедлайна const deadlineProvided = options.deadlineDate !== undefined; const nextDeadline = deadlineProvided ? options.deadlineDate || undefined : prevState.deadlineDate; const deadlineChanged = deadlineProvided && nextDeadline !== prevState.deadlineDate; /** @type {import('../types.js').LocalCPUIState} */ const state = { targetId: prevState.targetId, criticalThreshold, nearCriticalThreshold, }; if (nextDeadline) state.deadlineDate = nextDeadline; /** @type {import('../types.js').LocalCPResult} */ let result; if (deadlineChanged) { try { result = this._cp.computeLocalFloat({ databaseId, targetId: state.targetId, scheduleType: "current", deadlineDate: nextDeadline, criticalThreshold, nearCriticalThreshold, }); } catch (err) { console.error("[LocalCPStateService] Ошибка расчёта ЛКП:", err); return entry.result; // состояние не меняется (спека G) } } else { result = this._reclassify( entry.result, criticalThreshold, nearCriticalThreshold, ); } this._byDatabase.set(databaseId, { state, result }); this._emit(databaseId); return result; } /** * Сброс цели анализа; событие local-cp-change со state: null. * Сохранённые пороги не сбрасываются (это настройки анализа, а не цели). * @param {string} databaseId * @returns {void} */ clearTarget(databaseId) { this._byDatabase.delete(databaseId); this._emit(databaseId); } /** * Текущее состояние анализа БД. * @param {string} databaseId * @returns {import('../types.js').LocalCPUIState|null} */ getState(databaseId) { return this._byDatabase.get(databaseId)?.state ?? null; } /** * Последний результат расчёта (для поздно подключившихся таблиц). * @param {string} databaseId * @returns {import('../types.js').LocalCPResult|null} */ getResult(databaseId) { return this._byDatabase.get(databaseId)?.result ?? null; } /** * Полная инвалидация состояния БД: очистка состояния и кэша опций, * сброс кэшей LocalCriticalPathService, событие со state: null. * @param {string} databaseId * @returns {void} */ invalidate(databaseId) { this._byDatabase.delete(databaseId); this._defaults.delete(databaseId); this._targetOptionsCache.delete(databaseId); this._cp.invalidate(databaseId); this._emit(databaseId); } /** * Список работ/вех текущего графика для селектора цели. * Исключаются LOE (TT_LOE) и WBS-summary (TT_WBS; в модели DeepDive WBS — * отдельные узлы wbsById и в activitiesBySchedule не попадают, фильтр * оставлен для данных P6, где WBS-summary-работы встречаются). * Завершённые работы НЕ исключаются (выбор такой цели — легальный сценарий * с warning 'target-completed', TC-LCO-006). * Порядок: вехи первыми (по activityCode), затем работы (по activityCode). * Работы с кодом «Техзапуск»/«ВехаСМР» справочника «ЦПС_Отчетность» * помечаются isKeyMilestone (UI выносит их наверх списка). * Список кэшируется по databaseId, сброс — в invalidate(). * @param {string} databaseId * @returns {import('../types.js').LocalCPTargetOption[]} */ getTargetOptions(databaseId) { const cached = this._targetOptionsCache.get(databaseId); if (cached) return cached; /** @type {import('../types.js').LocalCPTargetOption[]} */ const milestones = []; /** @type {import('../types.js').LocalCPTargetOption[]} */ const tasks = []; const indexes = this._indexer.getIndexes(databaseId); const bySchedule = indexes?.activitiesBySchedule.get("current"); const keyType = indexes ? findCodeTypeByName(indexes, KEY_MILESTONE_CODE_TYPE) : undefined; if (bySchedule) { for (const a of bySchedule.values()) { if (a.taskType === "TT_LOE") continue; // гамак/LOE не входят в КП if (a.taskType === "TT_WBS") continue; // WBS-summary const isMilestone = Boolean(a.startDate) && a.startDate === a.endDate; let isKeyMilestone = false; if (keyType && indexes) { const valueId = indexes.activityCodesByType.get(a.id)?.get(keyType.typeId); const value = valueId != null ? indexes.codeValueById.get(valueId) : undefined; isKeyMilestone = value != null && KEY_MILESTONE_CODE_VALUES.has(value.shortName); } /** @type {import('../types.js').LocalCPTargetOption} */ const option = { activityId: a.id, activityCode: a.activityCode ?? "", taskName: a.taskName ?? "", isMilestone, isKeyMilestone, startDate: a.startDate ?? "", endDate: a.endDate ?? "", }; (isMilestone ? milestones : tasks).push(option); } } const byCode = ( /** @type {import('../types.js').LocalCPTargetOption} */ x, /** @type {import('../types.js').LocalCPTargetOption} */ y, ) => (x.activityCode < y.activityCode ? -1 : x.activityCode > y.activityCode ? 1 : 0); milestones.sort(byCode); tasks.sort(byCode); const options = milestones.concat(tasks); this._targetOptionsCache.set(databaseId, options); return options; } // ========================================================================= // Internals // ========================================================================= /** * Переклассификация criticality в копии результата по готовым * totalFloatLocal (без обратного прохода, спека F.1 п.3). * Правила: TF <= critical → 'critical', TF <= near → 'near', иначе 'normal'. * @private * @param {import('../types.js').LocalCPResult} result * @param {number} criticalThreshold * @param {number} nearCriticalThreshold * @returns {import('../types.js').LocalCPResult} */ _reclassify(result, criticalThreshold, nearCriticalThreshold) { /** @type {Map<number, import('../types.js').LocalFloatResult>} */ const results = new Map(); for (const [activityId, r] of result.results) { const tf = r.totalFloatLocal; const isCritical = tf <= criticalThreshold; results.set(activityId, { ...r, criticality: isCritical ? "critical" : tf <= nearCriticalThreshold ? "near" : "normal", isCritical, }); } return { ...result, results }; } /** * Публикация local-cp-change на document (bubbles+composed). * Без DOM (node) — no-op. * @private * @param {string} databaseId * @returns {void} */ _emit(databaseId) { if (typeof document === "undefined" || typeof document.dispatchEvent !== "function") { return; } const entry = this._byDatabase.get(databaseId); /** @type {import('../types.js').LocalCPChangeDetail} */ const detail = { databaseId, state: entry ? entry.state : null, result: entry ? entry.result : null, }; document.dispatchEvent( new CustomEvent("local-cp-change", { detail, bubbles: true, composed: true, }), ); } /** * Подписка на жизненный цикл БД (только при наличии document): * - database-removed → invalidate(databaseId); * - database-changed → re-emit для новой активной БД, чтобы таблицы * перечитали getResult (спека F.1 п.5). * @private * @returns {void} */ _subscribeToDocumentEvents() { if (typeof document === "undefined" || typeof document.addEventListener !== "function") { return; } document.addEventListener("database-removed", (e) => { const databaseId = /** @type {CustomEvent} */ (e).detail?.databaseId; if (databaseId) this.invalidate(databaseId); }); document.addEventListener("database-changed", (e) => { const databaseId = /** @type {CustomEvent} */ (e).detail?.databaseId; if (databaseId) this._emit(databaseId); }); } } const localCPStateService = new LocalCPStateService(); export { LocalCPStateService, localCPStateService, getLocalCPCellClass }; export default localCPStateService;