/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/LocalCriticalPathService.js
1 695 строк
73 KB
Starolat Sergei
fix: паритет расчёта локального критического пути с P6 (ограничения ранних дат, same-day handoff, маппинг полей календарей)
31 июл 2026, 11:58
31 июл 2026, 11:58
9e57305
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview LocalCriticalPathService — расчёт локального критического пути * и локального полного резерва (total float) незавершённых работ относительно * заданной вехи/работы обратным проходом CPM по рабочим календарям (SYS-024). * * Реализует спеку specs/system/LocalCriticalPathService.md v1.2.0: * - H.2 фильтр по scheduleType, исключение completed (actualEndDate) и TT_LOE; * - H.3 контур — reverse reachability (BFS) от цели; * - H.4 топология Кана + детект циклов (cycleNodes, без падения); * - H.5 якорь обратного прохода: manual deadlineDate > финишное ограничение цели * (CS_MANDFIN/CS_MEO/CS_MEOB, при двух — более ранняя дата) > плановый endDate; * - H.6 обратный проход (FS/SS/FF/SF, лаг по календарю предшественника, * dur_eff из ранних дат по календарю работы, in-progress от dataDate, * EF_eff = dataDate при endDate < dataDate); * - H.7 резервы и зоны критичности; * - H.9 driving chain от цели назад по связывающим связям; * - H.10 CSR на типизированных массивах, двухуровневый кэш * (граф+календари по (databaseId, scheduleType); результат по * (databaseId, scheduleType, targetId, anchorDate)). * - H.11 резервы работ-гамаков (TT_LOE) 3-го уровня: гамаку с кодом * «!Уровень графика» = 3 назначается минимальный резерв среди работ * с кодом «!Уровень графика» = 4 и тем же значением кода «TCM_WBS», * что и у гамака. Пустые коды и отсутствие рассчитанных резервов * обрабатываются пропуском (гамак без результата). * * dataDate: максимальная dataDate среди проектов indexes.projectsById * (детерминированно, не зависит от порядка вставки в Map). Если dataDate * не задана ни у одного проекта — «горение» (EF_eff = dataDate) не применяется, * а для выполняющихся работ dur_eff считается от actualStartDate (задокументированный * fallback; спека предполагает наличие DatabaseMetadata.dataDate). * * Чистый вычислитель: без DOM, без событий, без внешних зависимостей. */ import { dataIndexer, findCodeTypeByName } from "./DataIndexer.js"; import { buildCalendarIndex, workdaysBetween, addWorkdays, subWorkdays, isoToDays, } from "./CalendarMath.js"; /** Сентинел «бесконечность» для поздних дат. */ const INF = 0x7fffffff; /** Типы связей в компактном виде (Int8). */ const REL_FS = 0; const REL_SS = 1; const REL_FF = 2; const REL_SF = 3; /** @type {ReadonlyMap<string, number>} */ const REL_TYPE_MAP = new Map([ ["FS", REL_FS], ["SS", REL_SS], ["FF", REL_FF], ["SF", REL_SF], ]); /** Якорные финишные ограничения (SYS-024 H.5). */ const ANCHOR_CONSTRAINT_TYPES = new Set(["CS_MANDFIN", "CS_MEO", "CS_MEOB"]); /** * Стартовые ограничения, влияющие на прямой проход (ранние даты): * ES не раньше даты ограничения (P6: Start On or After / Start On / Mandatory Start). */ const EARLY_START_CONSTRAINT_TYPES = new Set(["CS_MSOA", "CS_MSO", "CS_MANDST"]); /** * Финишные ограничения, влияющие на прямой проход (ранние даты): * EF не раньше даты ограничения (P6: Finish On or After / Finish On / Mandatory Finish). * CS_MEOB (Finish On or Before) влияет только на поздние даты — здесь не учитывается. */ const EARLY_FINISH_CONSTRAINT_TYPES = new Set(["CS_MEOA", "CS_MEO", "CS_MANDFIN"]); /** * @typedef {Object} AnchorResolution * @property {number} anchorDays - Дата якоря (days since epoch) * @property {import('../types.js').LocalCPAnchorSource} anchorSource * @property {import('../types.js').LocalCPTargetConstraint|undefined} targetConstraint */ /** * @typedef {Object} GraphCache * @property {Int32Array} nodeIds - activityId по плотному индексу * @property {Map<number, number>} indexById - activityId → плотный индекс * @property {number} nodeCount * @property {number} edgeCount * @property {Int32Array} succOffsets - CSR последователей (N+1) * @property {Int32Array} succTargets * @property {Int8Array} succType * @property {Int16Array} succLag * @property {Int32Array} predOffsets - CSR предшественников (N+1) * @property {Int32Array} predTargets * @property {Int8Array} predType * @property {Int16Array} predLag * @property {Int32Array} startDays - хранимый ранний старт (int days) * @property {Int32Array} endDays - хранимый ранний финиш (int days) * @property {Int32Array} actualStartDays - фактический старт (int days; 0 если не начата) * @property {Int32Array} durEff - эффективная длительность (рабочие дни календаря работы) * @property {Int32Array} esEff - эффективный ранний старт (прямой проход от dataDate; fallback — startDays) * @property {Int32Array} efEff - эффективный ранний финиш (прямой проход от dataDate; fallback — endDays) * @property {Uint8Array} inProgress - работа выполняется (actualStartDate задана) * @property {Int32Array} cstrStartDays - мин. ранний старт из start-ограничений (int days; 0 если нет) * @property {Int32Array} cstrFinDays - мин. ранний финиш из finish-ограничений (int days; 0 если нет) * @property {Int32Array} calIdxByNode - индекс календаря в calendars (-1 → null, календарные дни) * @property {(import('../types.js').CalendarIndex|null)[]} calendars * @property {Int8Array} handoffShiftByCal - сдвиг передачи FS/SF по календарю (0 — same-day для «суточных» календарей dayHrCnt ≥ 22; 1 — следующий рабочий день) * @property {(import('../types.js').WorkCalendar|null)[]} rawCalendars - исходные календари (пересборка индексов при расширении диапазона) * @property {number} calRangeStart - текущий материализованный диапазон (начало) * @property {number} calRangeEnd - текущий материализованный диапазон (конец) * @property {number} maxOpShift - макс. сдвиг за одну календарную операцию (max |lag|, max dur_eff) * @property {'working'|'calendar'} calendarMode * @property {boolean} anyCalendarMissing - есть работы без календаря (при наличии календарей в БД) * @property {number} dataDateDays - data date (int days; NaN если не задана) * @property {Int32Array} _lf - буфер поздних финишей * @property {Int32Array} _ls - буфер поздних стартов * @property {Uint8Array} _visited - буфер контура * @property {Uint8Array} _excluded - буфер узлов петель * @property {Int32Array} _indeg - буфер входящих степеней * @property {Int32Array} _scratch - буфер степеней для trim циклов * @property {Int32Array} _queue - буфер очереди BFS/Кана * @property {Int32Array} _order - буфер топологического порядка */ /** * @typedef {Object} ResultCacheEntry * @property {number} targetId * @property {number} anchorDateDays * @property {import('../types.js').LocalCPAnchorSource} anchorSource * @property {import('../types.js').LocalCPTargetConstraint|undefined} targetConstraint * @property {'working'|'calendar'} calendarMode * @property {Int32Array} ids - activityId работ контура с результатами * @property {Int32Array} ls - поздние старты (int days) * @property {Int32Array} lf - поздние финиши (int days) * @property {Int32Array} tf - локальные резервы (рабочие дни календаря работы) * @property {number[]} drivingChain * @property {number[]} cycleNodes * @property {string[]} warnings * @property {number} durationMs - длительность исходного полного расчёта * @property {number} criticalThreshold - пороги, с которыми сохранён результат * @property {number} nearCriticalThreshold */ class LocalCriticalPathService { /** * @param {import('./DataIndexer.js').DataIndexer} [indexer] - поставщик индексов */ constructor(indexer = dataIndexer) { this._indexer = indexer; /** @type {Map<string, GraphCache>} ключ: `${databaseId}|${scheduleType}` */ this._graphCache = new Map(); /** @type {Map<string, ResultCacheEntry>} ключ: `${databaseId}|${scheduleType}|${targetId}|${anchorDays}` */ this._resultCache = new Map(); } // ========================================================================= // Public API (SYS-024, раздел G) // ========================================================================= /** * Полный расчёт: контур, обратный проход, резервы, критическая цепочка. * @param {import('../types.js').LocalCPOptions} options * @returns {import('../types.js').LocalCPResult} */ computeLocalFloat(options) { const t0 = performance.now(); const databaseId = options.databaseId; const targetId = options.targetId; const scheduleType = options.scheduleType || "current"; const criticalThreshold = options.criticalThreshold ?? 0; const nearCriticalThreshold = options.nearCriticalThreshold ?? 5; const indexes = this._indexer.getIndexes(databaseId); if (!indexes) { return this._emptyResult(targetId, ["target-not-found"], t0); } const activity = indexes.activitiesById.get(targetId); if (!activity || (activity.scheduleType && activity.scheduleType !== scheduleType)) { return this._emptyResult(targetId, ["target-not-found"], t0); } if (activity.actualEndDate) { // SYS-024 I: резерв до выполненной вехи бессмысленен return this._emptyResult(targetId, ["target-completed"], t0); } const graph = this._getGraph(indexes, databaseId, scheduleType); const targetIdx = graph.indexById.get(targetId); if (targetIdx === undefined) { // Работа есть в БД, но исключена из графа (LOE, WBS-summary, нет дат) return this._emptyResult(targetId, ["target-not-found"], t0); } const anchor = this._resolveAnchor(activity, options.deadlineDate, graph, targetIdx); // Кэш результата (H.10): попадание → только переклассификация criticality const resultKey = `${databaseId}|${scheduleType}|${targetId}|${anchor.anchorDays}`; const cached = this._resultCache.get(resultKey); if (cached) { return this._buildResultFromCache(cached, criticalThreshold, nearCriticalThreshold, t0); } const warnings = []; const relationships = indexes.relationships || []; if (relationships.length === 0) { warnings.push("no-relationships"); } // --- H.3: контур (reverse reachability, BFS по предшественникам) --- const contourCount = this._bfsContour(graph, targetIdx); // --- H.4: топология Кана + детект циклов --- const orderLen = this._topoSort(graph, contourCount); let cycleNodeIds = []; let finalOrderLen = orderLen; if (orderLen < contourCount) { cycleNodeIds = this._extractCycleNodes(graph, contourCount, orderLen); warnings.push("cycle-detected"); finalOrderLen = this._topoSort(graph, contourCount); } // --- H.5/H.6: обратный проход --- for (const cal of graph.calendars) { if (cal) cal.extrapolatedUsed = false; } this._backwardPass(graph, targetIdx, anchor.anchorDays, finalOrderLen); // --- H.7: резервы и зоны критичности --- let hasDatesBeforeDataDate = false; let hasCalendarExtrapolation = false; for (const cal of graph.calendars) { if (cal && cal.extrapolatedUsed) hasCalendarExtrapolation = true; } const hasDataDate = Number.isFinite(graph.dataDateDays); const order = graph._order; const ids = []; const lsArr = []; const lfArr = []; const tfArr = []; for (let i = 0; i < finalOrderLen; i++) { const u = order[i]; const lf = graph._lf[u]; if (lf === INF) continue; // узел без поздних дат (оборвано петлёй) — без результата // Факт «старых» дат: хранимый финиш незавершённой работы раньше Data Date if (hasDataDate && graph.endDays[u] < graph.dataDateDays) { hasDatesBeforeDataDate = true; } ids.push(graph.nodeIds[u]); lsArr.push(graph._ls[u]); lfArr.push(lf); tfArr.push( workdaysBetween(graph.efEff[u], lf, graph.calendars[graph.calIdxByNode[u]] ?? null), ); } // --- H.11: резервы гамаков (LOE 3-го уровня) по min резерву работ 4-го --- this._appendHammockFloats(indexes, scheduleType, ids, lsArr, lfArr, tfArr); // --- H.9: локальная критическая цепочка --- const drivingChain = this._buildDrivingChain(graph, targetIdx, finalOrderLen); if (graph.calendarMode === "calendar" || graph.anyCalendarMissing) { warnings.push("calendar-missing"); } if (hasCalendarExtrapolation) { warnings.push("calendar-extrapolated"); } if (hasDatesBeforeDataDate) { warnings.push("dates-before-data-date"); } const durationMs = performance.now() - t0; /** @type {ResultCacheEntry} */ const entry = { targetId, anchorDateDays: anchor.anchorDays, anchorSource: anchor.anchorSource, targetConstraint: anchor.targetConstraint, calendarMode: graph.calendarMode, ids: Int32Array.from(ids), ls: Int32Array.from(lsArr), lf: Int32Array.from(lfArr), tf: Int32Array.from(tfArr), drivingChain, cycleNodes: cycleNodeIds, warnings, durationMs, criticalThreshold, nearCriticalThreshold, }; this._resultCache.set(resultKey, entry); return this._buildResultFromCache(entry, criticalThreshold, nearCriticalThreshold, t0, durationMs); } /** * Результат из кэша без пересчёта. * @param {string} databaseId * @param {'current'|'target'} scheduleType * @param {number} targetId * @param {string} [deadlineDate] - ручной дедлайн (YYYY-MM-DD) * @returns {import('../types.js').LocalCPResult|null} */ getCachedResult(databaseId, scheduleType, targetId, deadlineDate) { const indexes = this._indexer.getIndexes(databaseId); if (!indexes) return null; const activity = indexes.activitiesById.get(targetId); if (!activity) return null; // Якорь planned требует efEff из графа — граф строится при необходимости const graph = this._getGraph(indexes, databaseId, scheduleType); const targetIdx = graph.indexById.get(targetId); if (targetIdx === undefined) return null; const anchor = this._resolveAnchor(activity, deadlineDate, graph, targetIdx); const key = `${databaseId}|${scheduleType}|${targetId}|${anchor.anchorDays}`; const entry = this._resultCache.get(key); if (!entry) return null; const t0 = performance.now(); return this._buildResultFromCache( entry, entry.criticalThreshold, entry.nearCriticalThreshold, t0, ); } /** * Сброс всех кэшей базы (граф, календарные индексы, результаты). * @param {string} databaseId * @returns {void} */ invalidate(databaseId) { for (const key of [...this._graphCache.keys()]) { if (key.startsWith(databaseId + "|")) this._graphCache.delete(key); } for (const key of [...this._resultCache.keys()]) { if (key.startsWith(databaseId + "|")) this._resultCache.delete(key); } } // ========================================================================= // Anchor (H.5) // ========================================================================= /** * Приоритет якорей: manual > constraint > planned. * Якорь planned — эффективный ранний финиш цели из прямого прохода от * dataDate (при отсутствии dataDate — хранимый endDate). * @private * @param {import('../types.js').Activity} activity * @param {string|undefined} deadlineDate * @param {GraphCache} graph * @param {number} targetIdx * @returns {AnchorResolution} */ _resolveAnchor(activity, deadlineDate, graph, targetIdx) { const manualDays = isoToDays(deadlineDate); if (Number.isFinite(manualDays)) { return { anchorDays: manualDays, anchorSource: "manual", targetConstraint: undefined }; } // Финишные ограничения цели; при двух — более ранняя (более жёсткая) дата /** @type {{type: string, dateDays: number}|undefined} */ let best; const pairs = [ [activity.cstrType, activity.cstrDate], [activity.cstrType2, activity.cstrDate2], ]; for (const [type, date] of pairs) { if (!type || !ANCHOR_CONSTRAINT_TYPES.has(type)) continue; const dateDays = isoToDays(date); if (!Number.isFinite(dateDays)) continue; if (!best || dateDays < best.dateDays) best = { type, dateDays }; } if (best) { return { anchorDays: best.dateDays, anchorSource: "constraint", targetConstraint: { type: best.type, dateDays: best.dateDays }, }; } // Плановый якорь: прямой EF цели (efEff = endDays, если нет dataDate) const anchorDays = graph.efEff[targetIdx] || isoToDays(activity.endDate) || isoToDays(activity.startDate) || 0; return { anchorDays, anchorSource: "planned", targetConstraint: undefined }; } // ========================================================================= // Graph cache (H.2, H.10) // ========================================================================= /** * Кэш графа и календарей по (databaseId, scheduleType). Строится один раз; * не перестраивается при смене цели/дедлайна/порогов. * @private * @param {import('../types.js').DatabaseIndexes} indexes * @param {string} databaseId * @param {'current'|'target'} scheduleType * @returns {GraphCache} */ _getGraph(indexes, databaseId, scheduleType) { const key = `${databaseId}|${scheduleType}`; const cached = this._graphCache.get(key); if (cached) return cached; const graph = this._buildGraph(indexes, scheduleType); this._graphCache.set(key, graph); return graph; } /** * Построение CSR-графа и календарных префикс-индексов. * @private * @param {import('../types.js').DatabaseIndexes} indexes * @param {'current'|'target'} scheduleType * @returns {GraphCache} */ _buildGraph(indexes, scheduleType) { // --- Узлы: scheduleType, не завершённые, не LOE (H.2) --- const bySchedule = indexes.activitiesBySchedule.get(scheduleType); const activities = bySchedule ? [...bySchedule.values()] : []; /** @type {import('../types.js').Activity[]} */ const nodes = []; for (const a of activities) { if (a.actualEndDate) continue; // завершённые исключены (логика исполнена) if (a.taskType === "TT_LOE") continue; // LOE/hammock не входят в критический путь nodes.push(a); } nodes.sort((x, y) => x.id - y.id); // детерминизм const n = nodes.length; const nodeIds = new Int32Array(n); const indexById = new Map(); const startDays = new Int32Array(n); const endDays = new Int32Array(n); const actualStartDays = new Int32Array(n); const inProgress = new Uint8Array(n); for (let i = 0; i < n; i++) { const a = nodes[i]; nodeIds[i] = a.id; indexById.set(a.id, i); let sd = isoToDays(a.startDate); let ed = isoToDays(a.endDate); if (!Number.isFinite(sd)) sd = ed; if (!Number.isFinite(ed)) ed = sd; // Обе даты отсутствуют — дефект данных; узел остаётся с датой 0, // на связях без реальных дат это даст экстремальные резервы, но не падение. startDays[i] = Number.isFinite(sd) ? sd : 0; endDays[i] = Number.isFinite(ed) ? ed : 0; const asd = isoToDays(a.actualStartDate); actualStartDays[i] = Number.isFinite(asd) ? asd : 0; inProgress[i] = a.actualStartDate ? 1 : 0; } // --- Ограничения, влияющие на прямой проход (ранние даты, методология P6): // start-ограничения → нижняя граница ES; finish-ограничения → нижняя граница EF. // При двух ограничениях одного класса — более поздняя (более жёсткая) дата. const cstrStartDays = new Int32Array(n); const cstrFinDays = new Int32Array(n); for (let i = 0; i < n; i++) { const a = nodes[i]; const pairs = [ [a.cstrType, a.cstrDate], [a.cstrType2, a.cstrDate2], ]; for (const [type, date] of pairs) { if (!type) continue; const dd = isoToDays(date); if (!Number.isFinite(dd)) continue; if (EARLY_START_CONSTRAINT_TYPES.has(type)) { if (dd > cstrStartDays[i]) cstrStartDays[i] = dd; } else if (EARLY_FINISH_CONSTRAINT_TYPES.has(type)) { if (dd > cstrFinDays[i]) cstrFinDays[i] = dd; } } } // --- Рёбра: связи, у которых оба конца — узлы графа (H.2 п.3) --- const relationships = indexes.relationships || []; /** @type {number[]} */ const src = []; /** @type {number[]} */ const dst = []; /** @type {number[]} */ const typ = []; /** @type {number[]} */ const lag = []; for (const r of relationships) { const p = indexById.get(r.predecessorId); const s = indexById.get(r.successorId); if (p === undefined || s === undefined) continue; // завершённый/чужой конец src.push(p); dst.push(s); typ.push(REL_TYPE_MAP.get(r.type) ?? REL_FS); lag.push(Math.max(-32767, Math.min(32767, Math.round(r.lagDays || 0)))); } const e = src.length; // --- CSR (H.10): succ + pred --- const succOffsets = new Int32Array(n + 1); const predOffsets = new Int32Array(n + 1); for (let i = 0; i < e; i++) { succOffsets[src[i] + 1]++; predOffsets[dst[i] + 1]++; } for (let i = 0; i < n; i++) { succOffsets[i + 1] += succOffsets[i]; predOffsets[i + 1] += predOffsets[i]; } const succTargets = new Int32Array(e); const succType = new Int8Array(e); const succLag = new Int16Array(e); const predTargets = new Int32Array(e); const predType = new Int8Array(e); const predLag = new Int16Array(e); const succCursor = succOffsets.slice(0, n); const predCursor = predOffsets.slice(0, n); for (let i = 0; i < e; i++) { const p = src[i]; const s = dst[i]; const si = succCursor[p]++; succTargets[si] = s; succType[si] = typ[i]; succLag[si] = lag[i]; const pi = predCursor[s]++; predTargets[pi] = p; predType[pi] = typ[i]; predLag[pi] = lag[i]; } // --- dataDate: максимальная среди проектов (детерминированно) --- let dataDateDays = NaN; let maxProjectEndDays = NaN; for (const project of indexes.projectsById.values()) { const dd = isoToDays(project.dataDate); if (Number.isFinite(dd) && (!Number.isFinite(dataDateDays) || dd > dataDateDays)) { dataDateDays = dd; } const pe = isoToDays(project.endDate); if (Number.isFinite(pe) && (!Number.isFinite(maxProjectEndDays) || pe > maxProjectEndDays)) { maxProjectEndDays = pe; } } // --- Календари: префикс-индексы с материализацией горизонта (H.8) --- const calendarsById = indexes.calendarsById; const calendarMode = calendarsById && calendarsById.size > 0 ? "working" : "calendar"; let minDay = Infinity; let maxDay = -Infinity; for (let i = 0; i < n; i++) { if (startDays[i] < minDay) minDay = startDays[i]; if (endDays[i] > maxDay) maxDay = endDays[i]; } if (Number.isFinite(dataDateDays)) { if (dataDateDays < minDay) minDay = dataDateDays; if (dataDateDays > maxDay) maxDay = dataDateDays; } if (Number.isFinite(maxProjectEndDays) && maxProjectEndDays > maxDay) { maxDay = maxProjectEndDays; } if (calendarsById) { for (const cal of calendarsById.values()) { if (cal.workHoursByDay.size === 0) continue; const days = [...cal.workHoursByDay.keys()]; const cMin = Math.min(...days); const cMax = Math.max(...days); if (cMin < minDay) minDay = cMin; if (cMax > maxDay) maxDay = cMax; } } if (!Number.isFinite(minDay)) minDay = 0; if (!Number.isFinite(maxDay)) maxDay = minDay + 365; // Материализация: rangeStart − 1 год … max(конец) + 2 года (H.8) const rangeStart = minDay - 365; const rangeEnd = maxDay + 730; /** @type {Map<number, number>} calendarId → позиция в calendars */ const calPosById = new Map(); /** @type {(import('../types.js').CalendarIndex|null)[]} */ const calendars = []; if (calendarMode === "working" && calendarsById) { for (const cal of calendarsById.values()) { calPosById.set(cal.calendarId, calendars.length); calendars.push(buildCalendarIndex(cal, { rangeStart, rangeEnd })); } } const calIdxByNode = new Int32Array(n).fill(-1); let anyCalendarMissing = false; if (calendarMode === "working") { for (let i = 0; i < n; i++) { const calId = nodes[i].calendarId; if (calId == null) { anyCalendarMissing = true; continue; } const pos = calPosById.get(calId); if (pos === undefined) { anyCalendarMissing = true; continue; } calIdxByNode[i] = pos; } } // --- dur_eff (H.6) и fallback для ранних дат --- const durEff = new Int32Array(n); const esEff = new Int32Array(n); const efEff = new Int32Array(n); const hasDataDate = Number.isFinite(dataDateDays); for (let i = 0; i < n; i++) { const cal = calendars[calIdxByNode[i]] ?? null; let dur; if (inProgress[i]) { // Выполняется: остаточная длительность от Data Date const from = hasDataDate ? dataDateDays : startDays[i]; dur = workdaysBetween(from, endDays[i], cal); } else { dur = workdaysBetween(startDays[i], endDays[i], cal); } durEff[i] = Math.max(0, dur); // Fallback: хранимые ранние даты (для узлов вне топопорядка / без dataDate) esEff[i] = startDays[i]; efEff[i] = endDays[i]; } // Максимальный сдвиг за одну операцию (для расширения диапазона календарей) let maxOpShift = 0; for (let i = 0; i < e; i++) { const absLag = lag[i] < 0 ? -lag[i] : lag[i]; if (absLag > maxOpShift) maxOpShift = absLag; } for (let i = 0; i < n; i++) { if (durEff[i] > maxOpShift) maxOpShift = durEff[i]; } // Исходные календари (для пересборки индексов при расширении диапазона) /** @type {(import('../types.js').WorkCalendar|null)[]} */ const rawCalendars = new Array(calendars.length).fill(null); if (calendarsById) { for (const [calId, pos] of calPosById) { rawCalendars[pos] = calendarsById.get(calId) ?? null; } } // Сдвиг передачи FS/SF по календарю предшественника (H.6, v1.4.0): // «суточные» календари (dayHrCnt ≥ 22 — 24/7, 22/7) покрывают все часы дня, // поэтому следующий рабочий период после финиша начинается в тот же день // (same-day handoff, сдвиг 0); остальные календари — сдвиг +1 рабочий день. // День с точностью до даты не знает времени финиша — это эвристика по // часовой ёмкости дня, подтверждённая сверкой с P6 на графиках заказчика. const handoffShiftByCal = new Int8Array(calendars.length).fill(1); for (let i = 0; i < calendars.length; i++) { const raw = rawCalendars[i]; if (raw && Number(raw.dayHrCnt) >= 22) handoffShiftByCal[i] = 0; } const graph = { nodeIds, indexById, nodeCount: n, edgeCount: e, succOffsets, succTargets, succType, succLag, predOffsets, predTargets, predType, predLag, startDays, endDays, actualStartDays, durEff, esEff, efEff, inProgress, cstrStartDays, cstrFinDays, calIdxByNode, calendars, handoffShiftByCal, rawCalendars, calRangeStart: rangeStart, calRangeEnd: rangeEnd, maxOpShift, calendarMode, anyCalendarMissing, dataDateDays, _lf: new Int32Array(n), _ls: new Int32Array(n), _visited: new Uint8Array(n), _excluded: new Uint8Array(n), _indeg: new Int32Array(n), _scratch: new Int32Array(n), _queue: new Int32Array(n), _order: new Int32Array(n), }; // --- Прямой проход от Data Date (H.6, методология P6): ранние даты // незавершённых работ пересчитываются вперёд от dataDate. Без dataDate — // хранимые ранние даты (efEff = endDays). Выполняется один раз на граф. if (hasDataDate) { this._forwardPass(graph); } return graph; } // ========================================================================= // Forward pass (H.6, прямой проход от Data Date) // ========================================================================= /** * Алгоритм Кана по ПОЛНОМУ графу (все узлы; узлы петель не упорядочиваются и * сохраняют хранимые ранние даты как fallback). Порядок пишется в _order. * @private * @param {GraphCache} g * @returns {number} длина топопорядка */ _topoSortAll(g) { const n = g.nodeCount; const indeg = g._indeg; const order = g._order; const queue = g._queue; indeg.fill(0); for (let u = 0; u < n; u++) { for (let i = g.predOffsets[u]; i < g.predOffsets[u + 1]; i++) indeg[u]++; } let head = 0; let tail = 0; for (let u = 0; u < n; u++) { if (indeg[u] === 0) queue[tail++] = u; } let len = 0; while (head < tail) { const u = queue[head++]; order[len++] = u; for (let i = g.succOffsets[u]; i < g.succOffsets[u + 1]; i++) { const s = g.succTargets[i]; indeg[s]--; if (indeg[s] === 0) queue[tail++] = s; } } return len; } /** * Прямой проход от Data Date: быстрый прогон + расширение диапазона и повтор * при выходе за материализованный диапазон (та же схема, что у обратного). * @private * @param {GraphCache} g */ _forwardPass(g) { const orderLen = this._topoSortAll(g); // Та же схема, что у обратного прохода: монотонное расширение диапазона, // до 5 попыток; далее — placeholder (патология данных, задокументировано). for (let attempt = 0; attempt < 5; attempt++) { const oob = this._forwardPassRun(g, orderLen); if (oob === null) return; this._extendCalendarRanges(g, oob.min, oob.max); } } /** * Один прогон прямого прохода в топологическом порядке. * * Методология (P6 считает вперёд от Data Date): * - не начата: ES_eff = max(dataDate, кандидаты предшественников), * EF_eff = addWorkdays(ES_eff, dur_eff); * - выполняется: ES_eff = actualStartDate (фикс), EF_eff = addWorkdays(dataDate, dur_eff); * - правила связей (лаги по календарю предшественника, инстант-арифметика): * FS: ES_s = addWorkdays(EF_p, lag + shiftP); SS: ES_s = addWorkdays(ES_p, lag); * FF: EF_s = addWorkdays(EF_p, lag); SF: EF_s = addWorkdays(subWorkdays(ES_p, 1), lag); * - EF-driven связи (FF/SF) дают кандидат на EF_s, затем согласование: * ES_s = max(ES_s, subWorkdays(EF_s, dur_eff)), EF_s = max(EF_s, addWorkdays(ES_s, dur_eff)). * - ограничения работ (методология P6): start-ограничения (CS_MSOA/CS_MSO/CS_MANDST) * поднимают ES до даты ограничения, finish-ограничения (CS_MEOA/CS_MEO/CS_MANDFIN) * поднимают EF (в т.ч. для выполняющихся работ). * * Горячий путь — как у обратного прохода: полный инлайн календарной * арифметики без вызовов в цикле; лаг — по календарю предшественника * (пер-ребёрное разрешение). Узлы вне топопорядка (петли) сохраняют * хранимые ранние даты (fallback). * * @private * @param {GraphCache} g * @param {number} orderLen * @returns {{min: number, max: number}|null} границы выхода за диапазон; null — чисто */ _forwardPassRun(g, orderLen) { const esEff = g.esEff; const efEff = g.efEff; const order = g._order; const predOffsets = g.predOffsets; const predTargets = g.predTargets; const predType = g.predType; const predLag = g.predLag; const durEff = g.durEff; const inProgress = g.inProgress; const cstrStartDays = g.cstrStartDays; const cstrFinDays = g.cstrFinDays; const actualStartDays = g.actualStartDays; const calIdxByNode = g.calIdxByNode; const calendars = g.calendars; const handoffShift = g.handoffShiftByCal; const dataDateDays = g.dataDateDays; let oobMin = INF; let oobMax = -INF; for (let oi = 0; oi < orderLen; oi++) { const u = order[oi]; const calU = calendars[calIdxByNode[u]] ?? null; /** @type {Uint32Array|null} */ const wdU = calU ? calU.workingDays : null; /** @type {Int32Array|null} */ const pcU = calU ? calU.prefixCount : null; const rsU = calU ? calU.rangeStart : 0; const reU = calU ? calU.rangeEnd : -1; const wlenU = wdU ? wdU.length : 0; const dur = durEff[u]; if (inProgress[u] === 1) { // Выполняется: старт зафиксирован фактом, финиш — остаток от Data Date esEff[u] = actualStartDays[u]; // inline addWorkdays(dataDateDays, dur, calU) if (wdU === null || pcU === null) { efEff[u] = dataDateDays + dur; } else if (dataDateDays >= rsU && dataDateDays <= reU) { const rk = pcU[dataDateDays - rsU]; const iw = rk - (dataDateDays > rsU ? pcU[dataDateDays - rsU - 1] : 0) === 1; const r2 = dur === 0 && !iw ? rk + 1 : rk + dur; if (r2 >= 1 && r2 <= wlenU) { efEff[u] = wdU[r2 - 1]; } else { if (dataDateDays < oobMin) oobMin = dataDateDays; if (dataDateDays > oobMax) oobMax = dataDateDays; efEff[u] = dataDateDays + dur; } } else { if (dataDateDays < oobMin) oobMin = dataDateDays; if (dataDateDays > oobMax) oobMax = dataDateDays; efEff[u] = dataDateDays + dur; } // Финишное ограничение (CS_MEOA/CS_MEO/CS_MANDFIN): EF не раньше даты if (cstrFinDays[u] > efEff[u]) efEff[u] = cstrFinDays[u]; continue; } let esU = dataDateDays; let efU = -INF; for (let i = predOffsets[u]; i < predOffsets[u + 1]; i++) { const p = predTargets[i]; const type = predType[i]; const lag = predLag[i]; const posP = calIdxByNode[p]; const calP = calendars[posP] ?? null; // Сдвиг передачи FS: +1 рабочий день, 0 — «суточные» календари (H.6, v1.4.0) const shiftP = posP >= 0 ? handoffShift[posP] : 1; /** @type {Uint32Array|null} */ const wd = calP ? calP.workingDays : null; /** @type {Int32Array|null} */ const pc = calP ? calP.prefixCount : null; const rs = calP ? calP.rangeStart : 0; const re = calP ? calP.rangeEnd : -1; const wlen = wd ? wd.length : 0; if (type === REL_FS || type === REL_SS) { // Кандидат на ранний СТАРТ: FS — addWorkdays(EF_p, lag + shiftP), // SS — addWorkdays(ES_p, lag). Инстант-правило P6 для FS: сдвиг по календарю. const base = type === REL_FS ? efEff[p] : esEff[p]; const effLag = type === REL_FS ? lag + shiftP : lag; let cand; if (wd === null || pc === null) { cand = base + effLag; } else if (base >= rs && base <= re) { const rk = pc[base - rs]; const iw = rk - (base > rs ? pc[base - rs - 1] : 0) === 1; const r2 = effLag === 0 ? iw ? rk : rk + 1 : effLag > 0 ? rk + effLag : iw ? rk + effLag : rk + effLag + 1; if (r2 >= 1 && r2 <= wlen) { cand = wd[r2 - 1]; } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = base + effLag; } } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = base + effLag; } if (cand > esU) esU = cand; } else if (type === REL_FF) { // Кандидат на ранний ФИНИШ: addWorkdays(EF_p, lag) const base = efEff[p]; let cand; if (wd === null || pc === null) { cand = base + lag; } else if (base >= rs && base <= re) { const rk = pc[base - rs]; const iw = rk - (base > rs ? pc[base - rs - 1] : 0) === 1; const r2 = lag === 0 ? (iw ? rk : rk + 1) : lag > 0 ? rk + lag : iw ? rk + lag : rk + lag + 1; if (r2 >= 1 && r2 <= wlen) { cand = wd[r2 - 1]; } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = base + lag; } } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = base + lag; } if (cand > efU) efU = cand; } else { // SF: EF_s = addWorkdays(subWorkdays(ES_p, 1), lag) — в рангах: // r_sub = (iw ? rk − 1 : rk), затем + lag (формула add для любого знака) const base = esEff[p]; let cand; if (wd === null || pc === null) { cand = base - 1 + lag; } else if (base >= rs && base <= re) { const rk = pc[base - rs]; const iw = rk - (base > rs ? pc[base - rs - 1] : 0) === 1; const r2 = (iw ? rk - 1 : rk) + lag; if (r2 >= 1 && r2 <= wlen) { cand = wd[r2 - 1]; } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = base - 1 + lag; } } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = base - 1 + lag; } if (cand > efU) efU = cand; } } // Стартовое ограничение (CS_MSOA/CS_MSO/CS_MANDST): ES не раньше даты if (cstrStartDays[u] > esU) esU = cstrStartDays[u]; // Согласование ES/EF: EF из ES при отсутствии EF-driven связей; // затем ES = max(ES, EF − dur), EF = max(EF, ES + dur) — по календарю работы if (efU === -INF) { // inline addWorkdays(esU, dur, calU) if (wdU === null || pcU === null) { efU = esU + dur; } else if (esU >= rsU && esU <= reU) { const rk = pcU[esU - rsU]; const iw = rk - (esU > rsU ? pcU[esU - rsU - 1] : 0) === 1; const r2 = dur === 0 && !iw ? rk + 1 : rk + dur; if (r2 >= 1 && r2 <= wlenU) { efU = wdU[r2 - 1]; } else { if (esU < oobMin) oobMin = esU; if (esU > oobMax) oobMax = esU; efU = esU + dur; } } else { if (esU < oobMin) oobMin = esU; if (esU > oobMax) oobMax = esU; efU = esU + dur; } } // Финишное ограничение (CS_MEOA/CS_MEO/CS_MANDFIN): EF не раньше даты; // далее блок согласования подтянет ES = max(ES, EF − dur) if (cstrFinDays[u] > efU) efU = cstrFinDays[u]; // inline subWorkdays(efU, dur, calU) let esFromEf; if (wdU === null || pcU === null) { esFromEf = efU - dur; } else if (efU >= rsU && efU <= reU) { const rk = pcU[efU - rsU]; const iw = rk - (efU > rsU ? pcU[efU - rsU - 1] : 0) === 1; const r2 = dur === 0 ? rk : iw ? rk - dur : rk - dur + 1; if (r2 >= 1 && r2 <= wlenU) { esFromEf = wdU[r2 - 1]; } else { if (efU < oobMin) oobMin = efU; if (efU > oobMax) oobMax = efU; esFromEf = efU - dur; } } else { if (efU < oobMin) oobMin = efU; if (efU > oobMax) oobMax = efU; esFromEf = efU - dur; } if (esFromEf > esU) esU = esFromEf; // inline addWorkdays(esU, dur, calU) let efFromEs; if (wdU === null || pcU === null) { efFromEs = esU + dur; } else if (esU >= rsU && esU <= reU) { const rk = pcU[esU - rsU]; const iw = rk - (esU > rsU ? pcU[esU - rsU - 1] : 0) === 1; const r2 = dur === 0 && !iw ? rk + 1 : rk + dur; if (r2 >= 1 && r2 <= wlenU) { efFromEs = wdU[r2 - 1]; } else { if (esU < oobMin) oobMin = esU; if (esU > oobMax) oobMax = esU; efFromEs = esU + dur; } } else { if (esU < oobMin) oobMin = esU; if (esU > oobMax) oobMax = esU; efFromEs = esU + dur; } if (efFromEs > efU) efU = efFromEs; esEff[u] = esU; efEff[u] = efU; } return oobMin === INF ? null : { min: oobMin, max: oobMax }; } // ========================================================================= // Contour / Topology (H.3, H.4) // ========================================================================= /** * BFS от цели по перевёрнутым рёбрам. Заполняет _visited и _order[0..count) * списком узлов контура. * @private * @param {GraphCache} g * @param {number} targetIdx * @returns {number} число узлов контура */ _bfsContour(g, targetIdx) { g._visited.fill(0); g._excluded.fill(0); const queue = g._queue; const order = g._order; let head = 0; let tail = 0; queue[tail++] = targetIdx; g._visited[targetIdx] = 1; while (head < tail) { const u = queue[head++]; for (let i = g.predOffsets[u]; i < g.predOffsets[u + 1]; i++) { const p = g.predTargets[i]; if (!g._visited[p]) { g._visited[p] = 1; queue[tail++] = p; } } } // Список узлов контура в порядке возрастания индексов (детерминизм) let count = 0; for (let u = 0; u < g.nodeCount; u++) { if (g._visited[u]) order[count++] = u; } return count; } /** * Алгоритм Кана на подграфе контура (рёбра pred→succ), узлы _excluded * пропускаются. Порядок записывается в _order[0..len). * @private * @param {GraphCache} g * @param {number} contourCount * @returns {number} длина топологического порядка */ _topoSort(g, contourCount) { const indeg = g._indeg; const order = g._order; const queue = g._queue; // contourList восстанавливаем из _visited (узлы контура) // Входящие степени только по рёбрам внутри контура for (let u = 0; u < g.nodeCount; u++) { if (g._visited[u]) indeg[u] = 0; } for (let u = 0; u < g.nodeCount; u++) { if (!g._visited[u] || g._excluded[u]) continue; for (let i = g.predOffsets[u]; i < g.predOffsets[u + 1]; i++) { const p = g.predTargets[i]; if (g._visited[p] && !g._excluded[p]) indeg[u]++; } } let head = 0; let tail = 0; for (let u = 0; u < g.nodeCount; u++) { if (g._visited[u] && !g._excluded[u] && indeg[u] === 0) queue[tail++] = u; } let len = 0; while (head < tail) { const u = queue[head++]; order[len++] = u; for (let i = g.succOffsets[u]; i < g.succOffsets[u + 1]; i++) { const s = g.succTargets[i]; if (!g._visited[s] || g._excluded[s]) continue; indeg[s]--; if (indeg[s] === 0) queue[tail++] = s; } } return len; } /** * Выделение узлов петель из «хвоста» Кана: итеративное стрипование узлов * с нулевой входящей/исходящей степенью внутри хвоста; остаток — узлы циклов. * Помечает их в _excluded и возвращает activityId. * @private * @param {GraphCache} g * @param {number} contourCount * @param {number} orderLen - длина порядка Кана (узлы после него — «хвост») * @returns {number[]} activityId узлов петель */ _extractCycleNodes(g, contourCount, orderLen) { // Хвост: узлы контура, не попавшие в порядок Кана. // Локальный маркер (путь редкий — петля в данных, аллокация допустима). const inTail = new Uint8Array(g.nodeCount); const order = g._order; const marked = new Uint8Array(g.nodeCount); for (let i = 0; i < orderLen; i++) marked[order[i]] = 1; const inL = g._indeg; const outL = g._scratch; const queue = g._queue; let head = 0; let tail = 0; for (let u = 0; u < g.nodeCount; u++) { if (!g._visited[u] || marked[u]) continue; inTail[u] = 1; } for (let u = 0; u < g.nodeCount; u++) { if (!inTail[u]) continue; let inn = 0; let out = 0; for (let i = g.predOffsets[u]; i < g.predOffsets[u + 1]; i++) { if (inTail[g.predTargets[i]]) inn++; } for (let i = g.succOffsets[u]; i < g.succOffsets[u + 1]; i++) { if (inTail[g.succTargets[i]]) out++; } inL[u] = inn; outL[u] = out; if (inn === 0 || out === 0) queue[tail++] = u; } // Стрипование: узлы без связей внутри хвоста — не на цикле while (head < tail) { const u = queue[head++]; if (!inTail[u]) continue; inTail[u] = 0; for (let i = g.predOffsets[u]; i < g.predOffsets[u + 1]; i++) { const p = g.predTargets[i]; if (!inTail[p]) continue; outL[p]--; if (outL[p] === 0 || inL[p] === 0) queue[tail++] = p; } for (let i = g.succOffsets[u]; i < g.succOffsets[u + 1]; i++) { const s = g.succTargets[i]; if (!inTail[s]) continue; inL[s]--; if (inL[s] === 0 || outL[s] === 0) queue[tail++] = s; } } // Остаток в inTail — узлы петель /** @type {number[]} */ const cycleIds = []; g._excluded.fill(0); for (let u = 0; u < g.nodeCount; u++) { if (inTail[u]) { g._excluded[u] = 1; cycleIds.push(g.nodeIds[u]); } } return cycleIds; } // ========================================================================= // Backward pass (H.6) // ========================================================================= /** * Обратный проход: быстрый прогон + при выходе за материализованный * диапазон календарей — расширение диапазона и один повторный прогон. * @private * @param {GraphCache} g * @param {number} targetIdx * @param {number} anchorDays - LF цели (якорь, H.5) * @param {number} orderLen - длина топопорядка в _order */ _backwardPass(g, targetIdx, anchorDays, orderLen) { // Каждый прогон, зафиксировавший выход за диапазон, расширяет его; диапазон // растёт монотонно, поэтому серия сходится за несколько итераций. После 5 // попыток принимаем placeholder-значения (патология данных, задокументировано). for (let attempt = 0; attempt < 5; attempt++) { const oob = this._backwardPassRun(g, targetIdx, anchorDays, orderLen); if (oob === null) return; this._extendCalendarRanges(g, oob.min, oob.max); } } /** * Расширение материализованного диапазона календарных индексов (редкий путь). * Новый диапазон покрывает зафиксированные выходы ± 7×maxOp дней + запас. * @private * @param {GraphCache} g * @param {number} lo - минимальный вышедший за диапазон день * @param {number} hi - максимальный вышедший за диапазон день */ _extendCalendarRanges(g, lo, hi) { const pad = 7 * g.maxOpShift + 31; const rangeStart = Math.min(lo, g.calRangeStart) - pad; const rangeEnd = Math.max(hi, g.calRangeEnd) + pad; for (let i = 0; i < g.calendars.length; i++) { const raw = g.rawCalendars[i]; if (!raw) continue; g.calendars[i] = buildCalendarIndex(raw, { rangeStart, rangeEnd }); } g.calRangeStart = rangeStart; g.calRangeEnd = rangeEnd; } /** * Один прогон обратного прохода в обратном топологическом порядке. * * Горячий путь (H.10): все CSR-массивы подняты в локальные переменные, * календарная арифметика полностью инлайнится (ранг по prefixCount + * рабочий день по workingDays — семантика идентична * CalendarMath.subWorkdays/addWorkdays). В теле цикла нет ни одного вызова * функции: измерено, что само присутствие вызова (даже на холодном пути) * мешает JIT оптимизировать цикл и стоит ~2 порядка на сетях 100k/500k. * Выход за материализованный диапазон НЕ обрабатывается в цикле — базис * фиксируется (oob), арифметика продолжается календарными днями, а после * прогона диапазон расширяется и прогон повторяется с нуля. * Инициализация цели: LF(M) = anchor; LS(M) = LF(M) − dur_eff(M) получается * из блока согласования при обработке цели (цель — последняя в топопорядке). * * @private * @param {GraphCache} g * @param {number} targetIdx * @param {number} anchorDays * @param {number} orderLen * @returns {{min: number, max: number}|null} границы выхода за диапазон; null — прогон чистый */ _backwardPassRun(g, targetIdx, anchorDays, orderLen) { const lf = g._lf; const ls = g._ls; lf.fill(INF); ls.fill(INF); lf[targetIdx] = anchorDays; const order = g._order; const succOffsets = g.succOffsets; const succTargets = g.succTargets; const succType = g.succType; const succLag = g.succLag; const visited = g._visited; const excluded = g._excluded; const durEff = g.durEff; const inProgress = g.inProgress; const calIdxByNode = g.calIdxByNode; const calendars = g.calendars; const handoffShift = g.handoffShiftByCal; let anyX = false; // факт использования экстраполированной зоны (за пределами реальных данных) let oobMin = INF; // выход за материализованный диапазон let oobMax = -INF; for (let oi = orderLen - 1; oi >= 0; oi--) { const u = order[oi]; const posU = calIdxByNode[u]; const cal = calendars[posU] ?? null; // Сдвиг передачи FS/SF по календарю предшественника (H.6, v1.4.0): // 0 — «суточные» календари (same-day handoff), 1 — следующий рабочий день const shiftU = posU >= 0 ? handoffShift[posU] : 1; // Плоские ссылки на префикс-индекс календаря узла (мономорфные локали) /** @type {Uint32Array|null} */ const wd = cal ? cal.workingDays : null; /** @type {Int32Array|null} */ const pc = cal ? cal.prefixCount : null; const rs = cal ? cal.rangeStart : 0; const re = cal ? cal.rangeEnd : -1; const wlen = wd ? wd.length : 0; const fr = cal ? cal.firstRealDay : 0; const lr = cal ? cal.lastRealDay : -1; const uInProgress = inProgress[u] === 1; let lfU = lf[u]; let lsU = ls[u]; for (let i = succOffsets[u]; i < succOffsets[u + 1]; i++) { const s = succTargets[i]; if (!visited[s] || excluded[s]) continue; const type = succType[i]; const lag = succLag[i]; // FS(0)/FF(2) → финишный срок, SS(1)/SF(3) → стартовый (младший бит типа) const toStart = (type & 1) === 1; // Старт выполняющейся работы зафиксирован фактом — SS/SF игнорируются if (toStart && uInProgress) continue; // FS/SS (типы 0,1) от LS(S), FF/SF (типы 2,3) от LF(S) const base = type <= 1 ? ls[s] : lf[s]; if (base === INF) continue; // --- inline subWorkdays(base, effLag, cal) — лаг по календарю предшественника. // Дневные конвенции P6 (инстант-арифметика), сдвиг shiftU по календарю (v1.4.0): // FS: поздний финиш предшественника снапится к концу предыдущего рабочего // дня относительно LS(S) → эффективный сдвиг = lag + shiftU. // SF: LF(S) — конец дня; как СТАРТ предшественника это начало следующего // рабочего периода → +shiftU к рангу после вычитания лага. const effLag = type === REL_FS ? lag + shiftU : lag; let cand; if (wd === null || pc === null) { cand = type === REL_SF ? base - lag + shiftU : base - effLag; // календарный режим } else if (base >= rs && base <= re) { if (base > lr || base - 1 < fr) anyX = true; const rk = pc[base - rs]; const iw = rk - (base > rs ? pc[base - rs - 1] : 0) === 1; let r2 = effLag === 0 ? rk : effLag > 0 ? iw ? rk - effLag : rk - effLag + 1 : rk - effLag; if (type === REL_SF) r2 += shiftU; if (r2 >= 1 && r2 <= wlen) { cand = wd[r2 - 1]; } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = type === REL_SF ? base - lag + shiftU : base - effLag; // placeholder } } else { if (base < oobMin) oobMin = base; if (base > oobMax) oobMax = base; cand = type === REL_SF ? base - lag + shiftU : base - effLag; // placeholder } // --- конец inline subWorkdays if (toStart) { if (cand < lsU) lsU = cand; } else { if (cand < lfU) lfU = cand; } } // Согласование LS/LF (H.6): восполнение, затем min-согласование. // lsFromLf = subWorkdays(lfU, dur); lfFromLs = addWorkdays(lsU, dur). const dur = durEff[u]; let lsFromLf = INF; if (lfU !== INF) { if (wd === null || pc === null) { lsFromLf = lfU - dur; } else if (lfU >= rs && lfU <= re) { if (lfU > lr || lfU - 1 < fr) anyX = true; const rk = pc[lfU - rs]; const iw = rk - (lfU > rs ? pc[lfU - rs - 1] : 0) === 1; const r2 = dur === 0 ? rk : iw ? rk - dur : rk - dur + 1; if (r2 >= 1 && r2 <= wlen) { lsFromLf = wd[r2 - 1]; } else { if (lfU < oobMin) oobMin = lfU; if (lfU > oobMax) oobMax = lfU; lsFromLf = lfU - dur; } } else { if (lfU < oobMin) oobMin = lfU; if (lfU > oobMax) oobMax = lfU; lsFromLf = lfU - dur; } } let lfFromLs = INF; if (lsU !== INF) { if (wd === null || pc === null) { lfFromLs = lsU + dur; } else if (lsU >= rs && lsU <= re) { if (lsU > lr || lsU - 1 < fr) anyX = true; const rk = pc[lsU - rs]; const iw = rk - (lsU > rs ? pc[lsU - rs - 1] : 0) === 1; const r2 = dur === 0 && !iw ? rk + 1 : rk + dur; if (r2 >= 1 && r2 <= wlen) { lfFromLs = wd[r2 - 1]; } else { if (lsU < oobMin) oobMin = lsU; if (lsU > oobMax) oobMax = lsU; lfFromLs = lsU + dur; } } else { if (lsU < oobMin) oobMin = lsU; if (lsU > oobMax) oobMax = lsU; lfFromLs = lsU + dur; } } if (lfU === INF) lfU = lfFromLs; if (lsU === INF) lsU = lsFromLf; if (lfU !== INF && lsU !== INF) { if (lsFromLf < lsU) { lsU = lsFromLf; // LF = min(LF, addWorkdays(обновлённый LS, dur)) — inline addWorkdays let lf2; if (wd === null || pc === null) { lf2 = lsU + dur; } else if (lsU >= rs && lsU <= re) { if (lsU > lr || lsU - 1 < fr) anyX = true; const rk = pc[lsU - rs]; const iw = rk - (lsU > rs ? pc[lsU - rs - 1] : 0) === 1; const r2 = dur === 0 && !iw ? rk + 1 : rk + dur; if (r2 >= 1 && r2 <= wlen) { lf2 = wd[r2 - 1]; } else { if (lsU < oobMin) oobMin = lsU; if (lsU > oobMax) oobMax = lsU; lf2 = lsU + dur; } } else { if (lsU < oobMin) oobMin = lsU; if (lsU > oobMax) oobMax = lsU; lf2 = lsU + dur; } if (lf2 < lfU) lfU = lf2; } else { if (lfFromLs < lfU) lfU = lfFromLs; } } lf[u] = lfU; ls[u] = lsU; } if (anyX) { for (const c of calendars) { if (c) c.extrapolatedUsed = true; } } return oobMin === INF ? null : { min: oobMin, max: oobMax }; } // ========================================================================= // Hammock floats (H.11) // ========================================================================= /** * Резервы работ-гамаков (TT_LOE) 3-го уровня графика. * * Гамаки исключены из CPM-графа (H.2), поэтому их резерв выводится из * рассчитанных резервов обычных работ: гамаку с кодом «!Уровень графика» = 3 * назначается минимальный totalFloat среди работ с кодом «!Уровень графика» = 4 * и тем же значением кода «TCM_WBS», что назначен гамаку. Поздние даты для * гамака не рассчитываются (он вне CPM-графа) — в `lateStartDays`/`lateFinishDays` * подставляются собственные даты гамака. * * Обработка пустых значений (гамак остаётся без результата): * - справочники «!Уровень графика»/«TCM_WBS» отсутствуют в БД — no-op; * - у гамака нет кода «TCM_WBS» или нет кода «!Уровень графика» = 3 — пропуск; * - работы 4-го уровня без кода «TCM_WBS» или без рассчитанного резерва * (вне контура, оборваны петлёй) не участвуют в min; * - для кода гамака нет ни одной работы 4-го уровня с резервом — пропуск. * * Результаты дописываются в параллельные массивы (in-place). * @private * @param {import('../types.js').DatabaseIndexes} indexes * @param {'current'|'target'} scheduleType * @param {number[]} ids - activityId работ с результатами (дополняется) * @param {number[]} lsArr - поздние старты (дополняется) * @param {number[]} lfArr - поздние финиши (дополняется) * @param {number[]} tfArr - локальные резервы (дополняется) * @returns {void} */ _appendHammockFloats(indexes, scheduleType, ids, lsArr, lfArr, tfArr) { const levelType = findCodeTypeByName(indexes, "!Уровень графика"); const wbsType = findCodeTypeByName(indexes, "TCM_WBS"); if (!levelType || !wbsType) return; const bySchedule = indexes.activitiesBySchedule.get(scheduleType); if (!bySchedule) return; // activityId → позиция результата (резерв + поздние даты) /** @type {Map<number, number>} */ const posById = new Map(); for (let i = 0; i < ids.length; i++) posById.set(ids[i], i); /** * Уровень графика работы по коду «!Уровень графика» (shortName значения). * @param {number} activityId * @returns {string} "" — код не назначен */ const levelOf = (activityId) => { const valueId = indexes.activityCodesByType.get(activityId)?.get(levelType.typeId); if (valueId == null) return ""; const value = indexes.codeValueById.get(valueId); return String(value?.shortName ?? "").trim(); }; // Минимальный резерв работ 4-го уровня по значению кода TCM_WBS /** @type {Map<number, number>} codeValueId TCM_WBS → min totalFloat */ const minByWbsValue = new Map(); /** @type {import('../types.js').Activity[]} */ const hammocks = []; for (const a of bySchedule.values()) { const level = levelOf(a.id); if (level === "3") { if (a.taskType === "TT_LOE") hammocks.push(a); continue; } if (level !== "4") continue; const wbsValueId = indexes.activityCodesByType.get(a.id)?.get(wbsType.typeId); if (wbsValueId == null) continue; // работа 4-го уровня без кода TCM_WBS const pos = posById.get(a.id); if (pos === undefined) continue; // резерв не рассчитан (вне контура и т.п.) const tf = tfArr[pos]; const prev = minByWbsValue.get(wbsValueId); if (prev === undefined || tf < prev) { minByWbsValue.set(wbsValueId, tf); } } for (const h of hammocks) { const wbsValueId = indexes.activityCodesByType.get(h.id)?.get(wbsType.typeId); if (wbsValueId == null) continue; // у гамака нет кода TCM_WBS const tf = minByWbsValue.get(wbsValueId); if (tf === undefined) continue; // нет работ 4-го уровня с резервом по этому коду // Поздние даты для гамака не рассчитываются (он вне CPM-графа) — // подставляются собственные даты гамака. const ls = isoToDays(h.startDate); const lf = isoToDays(h.endDate); ids.push(h.id); lsArr.push(Number.isFinite(ls) ? ls : 0); lfArr.push(Number.isFinite(lf) ? lf : 0); tfArr.push(tf); } } // ========================================================================= // Driving chain (H.9) // ========================================================================= /** * Локальная критическая цепочка от цели назад: на каждом узле выбирается * предшественник, чья связь связывающая (её кандидат равен принятому позднему * сроку узла), с минимальным TF_local; при равенстве — с более ранним финишем. * @private * @param {GraphCache} g * @param {number} targetIdx * @param {number} orderLen * @returns {number[]} activityId от цели к началу */ _buildDrivingChain(g, targetIdx, orderLen) { // TF по узлам (для выбора среди связывающих предшественников) const tfByIdx = g._scratch; const order = g._order; for (let i = 0; i < orderLen; i++) { const u = order[i]; const lf = g._lf[u]; tfByIdx[u] = lf === INF ? INF : workdaysBetween( g.efEff[u], lf, g.calendars[g.calIdxByNode[u]] ?? null, ); } const chain = [g.nodeIds[targetIdx]]; const inChain = new Set([targetIdx]); let u = targetIdx; for (;;) { let bestP = -1; let bestTf = INF; let bestEnd = INF; for (let i = g.predOffsets[u]; i < g.predOffsets[u + 1]; i++) { const p = g.predTargets[i]; if (!g._visited[p] || g._excluded[p] || inChain.has(p)) continue; if (g._lf[p] === INF || g._ls[p] === INF) continue; const type = g.predType[i]; const lag = g.predLag[i]; const posP = g.calIdxByNode[p]; const calP = g.calendars[posP] ?? null; const shiftP = posP >= 0 ? g.handoffShiftByCal[posP] : 1; // Связывающая связь: кандидат от этой связи равен принятому позднему сроку p. // Дневные конвенции P6: FS — сдвиг lag+shiftP; SF — +shiftP после лага (v1.4.0). let binding = false; if (type === REL_FS) { binding = subWorkdays(g._ls[u], lag + shiftP, calP) === g._lf[p]; } else if (type === REL_FF) { binding = subWorkdays(g._lf[u], lag, calP) === g._lf[p]; } else if (type === REL_SS) { binding = subWorkdays(g._ls[u], lag, calP) === g._ls[p]; } else { binding = addWorkdays(subWorkdays(g._lf[u], lag, calP), shiftP, calP) === g._ls[p]; } if (!binding) continue; const tf = tfByIdx[p]; const end = g.endDays[p]; if (tf < bestTf || (tf === bestTf && end < bestEnd)) { bestTf = tf; bestEnd = end; bestP = p; } } if (bestP < 0) break; chain.push(g.nodeIds[bestP]); inChain.add(bestP); // Стоп: достигнут data date — старт выполняющейся работы зафиксирован фактом if (g.inProgress[bestP] === 1) break; u = bestP; } return chain; } // ========================================================================= // Result helpers // ========================================================================= /** * Сборка LocalCPResult из кэш-записи с переклассификацией по порогам (H.10 п.5). * @private * @param {ResultCacheEntry} entry * @param {number} criticalThreshold * @param {number} nearCriticalThreshold * @param {number} t0 * @param {number} [durationMs] - фактическая длительность полного расчёта * @returns {import('../types.js').LocalCPResult} */ _buildResultFromCache(entry, criticalThreshold, nearCriticalThreshold, t0, durationMs) { /** @type {Map<number, import('../types.js').LocalFloatResult>} */ const results = new Map(); for (let i = 0; i < entry.ids.length; i++) { const tf = entry.tf[i]; const isCritical = tf <= criticalThreshold; const criticality = isCritical ? "critical" : tf <= nearCriticalThreshold ? "near" : "normal"; results.set(entry.ids[i], { lateStartDays: entry.ls[i], lateFinishDays: entry.lf[i], totalFloatLocal: tf, criticality, isCritical, }); } return { targetId: entry.targetId, anchorDateDays: entry.anchorDateDays, anchorSource: entry.anchorSource, targetConstraint: entry.targetConstraint, calendarMode: entry.calendarMode, results, drivingChain: entry.drivingChain, cycleNodes: entry.cycleNodes, warnings: entry.warnings, durationMs: durationMs ?? performance.now() - t0, }; } /** * Пустой результат для ошибочных сценариев (раздел I). * @private * @param {number} targetId * @param {string[]} warnings * @param {number} t0 * @returns {import('../types.js').LocalCPResult} */ _emptyResult(targetId, warnings, t0) { return { targetId, anchorDateDays: 0, anchorSource: "planned", calendarMode: "calendar", results: new Map(), drivingChain: [], cycleNodes: [], warnings, durationMs: performance.now() - t0, }; } } const localCriticalPathService = new LocalCriticalPathService(); export { LocalCriticalPathService, localCriticalPathService }; export default localCriticalPathService;