/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/DataService.js
2 396 строк
84 KB
Starolat Sergei
fix: паритет расчёта локального критического пути с P6 (ограничения ранних дат, same-day handoff, маппинг полей календарей)
31 июл 2026, 11:58
31 июл 2026, 11:58
9e57305
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview DataService - Слой доступа к данным (v4.0.0) * @module DataService * @version 4.0.0 * * Архитектура v4.0: * - JS Data Engine является единственным движком данных. * - Данные хранятся в in-memory индексах DataIndexer. * - Загрузка из Primavera API и .dddb файлов. * - Множественные БД для сравнительного анализа. */ import config from "../config.js"; import { fetchWithAuth } from "./auth/index.js"; import weekIndexService from "./WeekIndexService.js"; import dataIndexer from "./DataIndexer.js"; import binaryDeserializer from "./BinaryDeserializer.js"; import binarySerializer from "./BinarySerializer.js"; import summaryJsQueryEngine from "./SummaryJsQueryEngine.js"; import dataServiceJsAdapter from "./DataServiceJsAdapter.js"; import wbsTableDataProvider from "./WbsTableDataProvider.js"; import materialDeliveryService from "./MaterialDeliveryService.js"; /** * @typedef {import('../types.js').Activity} Activity * @typedef {import('../types.js').Project} Project * @typedef {import('../types.js').CodeType} CodeType * @typedef {import('../types.js').CodeValue} CodeValue * @typedef {import('../types.js').WBS} WBS * @typedef {import('../types.js').Resource} Resource * @typedef {import('../types.js').ResourceAssignment} ResourceAssignment * @typedef {import('../types.js').Filter} Filter * @typedef {import('../types.js').GroupedResult} GroupedResult * @typedef {import('../types.js').DatabaseInfo} DatabaseInfo * @typedef {import('../types.js').DatabaseMetadata} DatabaseMetadata * @typedef {import('../types.js').InitConfig} InitConfig * @typedef {import('../types.js').MaterialVolumeItem} MaterialVolumeItem * @typedef {import('../types.js').MaterialResourceItem} MaterialResourceItem * @typedef {import('../types.js').MaterialVolumeQueryOptions} MaterialVolumeQueryOptions * @typedef {import('../types.js').ProjectStatsResult} ProjectStatsResult * @typedef {import('../types.js').MaterialDelivery} MaterialDelivery * @typedef {import('../types.js').MaterialDeliveryImportOptions} MaterialDeliveryImportOptions * @typedef {import('../types.js').MaterialDeliveryImportResult} MaterialDeliveryImportResult */ /** * DataService - центральный сервис для работы с данными * @class */ class DataService { constructor() { /** @type {boolean} */ this.isInitialized = false; /** @type {string|null} */ this.primaryDatabaseId = null; // API Configuration из централизованного config.js this.USE_LOCAL_API = config.USE_LOCAL_API; this.API_CONFIG = config.API_CONFIG; } // ==================== Initialization ==================== /** * Инициализация DataService * @param {InitConfig} [initConfig] * @returns {Promise<void>} */ async initialize(initConfig = {}) { if (this.isInitialized) { console.log("[DataService] Already initialized"); return; } console.log("[DataService] Initializing JS Data Engine..."); this.isInitialized = true; console.log("[DataService] Initialized successfully"); } // ==================== Database Management ==================== /** * Синхронизация данных из Primavera API * @param {string[]} shortNames - short_name проектов (KSG ID) * @param {string} authToken - Токен авторизации * @param {Function} [progressCallback] - Callback для обновления прогресса (percent, phase) * @returns {Promise<string>} ID созданной базы * @deprecated Используйте syncPairFromPrimavera для загрузки пар графиков в одну БД */ async syncFromPrimavera(shortNames, authToken, progressCallback) { this._ensureInitialized(); console.log("[DataService] Syncing from Primavera API...", shortNames); try { // Конвертируем short_name в PROJ_ID (10-20%) progressCallback?.(10, "resolve_ids"); const projIds = await this._resolveShortNamesToProjIds( shortNames, authToken, ); if (projIds.length === 0) { throw new Error("No valid projects found for the given short_names"); } // Получаем все данные из API (20-80%) progressCallback?.(20, "fetch_data"); const apiData = await this._fetchAllDataFromAPI( projIds, authToken, progressCallback, ); // Импортируем в JS Data Engine (80-100%) progressCallback?.(80, "import_db"); const databaseId = await dataIndexer.buildIndexesFromAPI( apiData, `Primavera-${shortNames.join(",")}`, { scheduleType: "current" }, ); this.primaryDatabaseId = databaseId; // Уведомляем о готовности progressCallback?.(100, "complete"); this._dispatchEvent("database-ready", { databaseId, source: "api" }); return databaseId; } catch (error) { console.error("[DataService] Sync failed:", error); throw error; } } /** * Синхронизация ПАРЫ графиков (current + target) из Primavera API в одну БД * @param {Object} pairConfig - Конфигурация пары графиков * @param {string} pairConfig.currentShortName - short_name текущего графика * @param {string} pairConfig.targetShortName - short_name целевого графика * @param {string} authToken - Токен авторизации * @param {Function} [progressCallback] - Callback для обновления прогресса (percent, phase) * @param {Object} [options] - Дополнительные опции * @param {string} [options.databaseName] - Имя базы данных * @returns {Promise<string>} ID созданной базы */ async syncPairFromPrimavera( pairConfig, authToken, progressCallback, options = {}, ) { this._ensureInitialized(); const { currentShortName, targetShortName } = pairConfig; const databaseName = options.databaseName || `Pair-${currentShortName}-${targetShortName}`; console.log("[DataService] Syncing pair from Primavera API...", { currentShortName, targetShortName, }); let databaseId = null; let isNewDatabase = false; try { // ========== ЗАГРУЗКА CURRENT ГРАФИКА ========== progressCallback?.(5, "resolve_current_ids"); const currentProjIds = await this._resolveShortNamesToProjIds( [currentShortName], authToken, ); if (currentProjIds.length === 0) { throw new Error( `Current schedule "${currentShortName}" not found in Primavera`, ); } progressCallback?.(10, "fetch_current_data"); const currentApiData = await this._fetchAllDataFromAPI( currentProjIds, authToken, (percent, phase) => { // Масштабируем прогресс: 10-45% const scaledPercent = 10 + Math.round((percent / 100) * 35); progressCallback?.(scaledPercent, `current_${phase}`); }, ); progressCallback?.(45, "import_current_db"); isNewDatabase = !databaseId; databaseId = await dataIndexer.buildIndexesFromAPI( currentApiData, databaseName, { scheduleType: "current" }, ); // ========== ЗАГРУЗКА TARGET ГРАФИКА ========== progressCallback?.(50, "resolve_target_ids"); const targetProjIds = await this._resolveShortNamesToProjIds( [targetShortName], authToken, ); if (targetProjIds.length === 0) { throw new Error( `Target schedule "${targetShortName}" not found in Primavera`, ); } progressCallback?.(55, "fetch_target_data"); const targetApiData = await this._fetchAllDataFromAPI( targetProjIds, authToken, (percent, phase) => { // Масштабируем прогресс: 55-90% const scaledPercent = 55 + Math.round((percent / 100) * 35); progressCallback?.(scaledPercent, `target_${phase}`); }, ); progressCallback?.(90, "import_target_db"); await dataIndexer.buildIndexesFromAPI(targetApiData, databaseName, { scheduleType: "target", existingDatabaseId: databaseId, }); // Инвалидируем кэш накопительных трудозатрат, т.к. в БД появились новые данные target-графика if (this._cumulativeCache && databaseId) { const prefix = `${databaseId}|`; for (const key of this._cumulativeCache.keys()) { if (key.startsWith(prefix)) { this._cumulativeCache.delete(key); } } } // Инвалидируем range-кэш if (databaseId) { this._invalidateRangeCache(databaseId); } this.primaryDatabaseId = databaseId; this.setActiveDatabase(databaseId); // Уведомляем о готовности только после полной загрузки пары progressCallback?.(100, "complete"); if (isNewDatabase) { this._dispatchEvent("database-added", { databaseId, name: databaseName, source: "api", }); } this._dispatchEvent("database-ready", { databaseId, source: "api", pairLoaded: true, currentShortName, targetShortName, }); console.log( `[DataService] Pair loaded into database ${databaseId}: current=${currentShortName}, target=${targetShortName}`, ); return databaseId; } catch (error) { console.error("[DataService] Pair sync failed:", error); // Если создали БД, но потом упали - очищаем if (databaseId) { await this.closeDatabase(databaseId); } throw error; } } /** * Конвертация short_name в PROJ_ID * @private * @param {string[]} shortNames * @param {string} authToken * @returns {Promise<number[]>} */ async _resolveShortNamesToProjIds(shortNames, authToken) { const baseUrl = config.USE_LOCAL_API ? config.API_CONFIG.local.SQL : config.API_CONFIG.production.SQL; // Прямая подстановка значений в SQL (как в WeeklyReport) const escapedNames = shortNames .map((name) => `'${name.replace(/'/g, "''")}'`) .join(","); const sql = `SELECT PROJ_ID, PROJ_SHORT_NAME FROM ADMUSER.PROJECT WHERE PROJ_SHORT_NAME IN (${escapedNames}) AND DELETE_SESSION_ID IS NULL`; console.log("[DataService] Resolving short_names to PROJ_IDs:", shortNames); const rows = await this._executePrimaveraQuery( sql, authToken, baseUrl, false, ); // API возвращает поля в оригинальном регистре (Oracle) - проверяем оба варианта как в AppSettingsModal const foundIds = rows .map((r) => r.PROJ_ID || r.proj_id) .filter((id) => id !== undefined); const foundNames = rows .map((r) => r.PROJ_SHORT_NAME || r.proj_short_name) .filter((name) => name !== undefined); console.log("[DataService] Found projects:", foundNames, "IDs:", foundIds); // Проверяем какие short_name не найдены const notFound = shortNames.filter((name) => !foundNames.includes(name)); if (notFound.length > 0) { console.warn("[DataService] Projects not found in Primavera:", notFound); } return foundIds; } /** * Загрузка из файла .dddb / .dddb.zip / .dddb.zst / .dddb.gz * @param {File} file - Файл .dddb * @returns {Promise<string>} ID созданной базы */ async loadFromFile(file) { this._ensureInitialized(); console.log("[DataService] Loading from file:", file.name); try { const buffer = await file.arrayBuffer(); // Проверяем .dddb формат (JS Data Engine) const isDddb = file.name.endsWith(".dddb") || file.name.endsWith(".dddb.zip") || file.name.endsWith(".dddb.zst") || file.name.endsWith(".dddb.gz") || binaryDeserializer.isDDDB(buffer); if (!isDddb) { throw new Error( "Unsupported file format. Only .dddb / .dddb.zip / .dddb.zst / .dddb.gz are supported.", ); } console.log( "[DataService] Detected .dddb file, loading into JS Data Engine...", ); const databaseId = await dataIndexer.buildIndexesFromBinary( buffer, file.name, ); if (!this.primaryDatabaseId) { this.primaryDatabaseId = databaseId; } const info = dataIndexer.getDatabaseInfo(databaseId); this._dispatchEvent("database-added", { databaseId, name: info?.name || file.name, }); return databaseId; } catch (error) { console.error("[DataService] File load failed:", error); throw error; } } /** * Экспорт базы в файл .dddb / .dddb.zip (бинарный формат JS Data Engine). * Сжатие zip применяется, если filename заканчивается на .zip (или .zst). * @param {string} [databaseId] - ID базы * @param {string} [filename] - Имя файла * @param {Function} [onProgress] - Callback прогресса (percent) => void * @returns {Promise<void>} */ async exportToFile(databaseId, filename, onProgress) { const id = databaseId || this.getActiveDatabaseId(); return this._exportJsToFile(id, filename, onProgress); } /** * Экспорт JS-индексов в файл .dddb / .dddb.zip (Phase 4). * @private * @param {string} databaseId * @param {string} [filename] * @param {Function} [onProgress] * @returns {Promise<void>} */ async _exportJsToFile(databaseId, filename, onProgress) { const indexes = dataIndexer.getIndexes(databaseId); if (!indexes) { throw new Error(`JS indexes not found for database ${databaseId}`); } const defaultName = filename || `${indexes.name.replace(/[^a-z0-9]/gi, "_")}_${new Date().toISOString().split("T")[0]}.dddb.zip`; await binarySerializer.serializeToFile(indexes, defaultName, {}, onProgress); console.log("[DataService] Exported JS database to:", defaultName); } /** * Импорт базы данных из файла * @param {ArrayBuffer} fileBuffer - Буфер файла * @param {string} filename - Имя файла * @returns {Promise<{success: boolean, databaseId?: string, tableCount?: number, error?: string}>} */ async importDatabaseFromFile(fileBuffer, filename) { this._ensureInitialized(); try { // Проверяем .dddb формат (JS Data Engine) const isDddb = filename.endsWith(".dddb") || filename.endsWith(".dddb.zip") || filename.endsWith(".dddb.zst") || filename.endsWith(".dddb.gz") || binaryDeserializer.isDDDB(fileBuffer); if (!isDddb) { return { success: false, error: "Unsupported file format. Only .dddb / .dddb.zip / .dddb.zst / .dddb.gz are supported.", }; } console.log( "[DataService] Detected .dddb file, importing into JS Data Engine...", ); const databaseId = await dataIndexer.buildIndexesFromBinary( fileBuffer, filename, ); this.primaryDatabaseId = databaseId; this.setActiveDatabase(databaseId); // Уведомляем UI о добавлении и готовности БД (аналогично загрузке из API). // source: 'file' позволяет AppController выполнить хард-ресет состояния. this._dispatchEvent("database-added", { databaseId, name: filename, source: "file", }); this._dispatchEvent("database-ready", { databaseId, source: "file", }); return { success: true, databaseId, tableCount: 0, }; } catch (error) { console.error( "[DataService] Failed to import database from file:", error, ); return { success: false, error: error.message, }; } } /** * Установка активной базы данных * @param {string} databaseId */ setActiveDatabase(databaseId) { if (dataIndexer.getIndexes(databaseId)) { dataIndexer.setActiveDatabase(databaseId); } if (this._cumulativeCache) { this._cumulativeCache.clear(); } if (this._rangeCache) { this._rangeCache.clear(); } this._dispatchEvent("database-changed", { databaseId }); } /** * Получение ID активной базы * @returns {string|null} */ getActiveDatabaseId() { return dataIndexer.getActiveDatabaseId() || this.primaryDatabaseId; } /** * Получение списка загруженных баз * @returns {DatabaseInfo[]} */ getLoadedDatabases() { return dataIndexer.getLoadedDatabases(); } /** * Закрытие базы данных * @param {string} databaseId */ async closeDatabase(databaseId) { // Очищаем поставки материалов перед удалением индексов, // чтобы MaterialDeliveryService мог корректно очистить derived indexes. materialDeliveryService.clearDeliveries(databaseId); if (dataIndexer.getIndexes(databaseId)) { dataIndexer.dropIndexes(databaseId); } if (this._cumulativeCache) { // Удаляем записи только для закрываемой БД const prefix = `${databaseId}|`; for (const key of this._cumulativeCache.keys()) { if (key.startsWith(prefix)) { this._cumulativeCache.delete(key); } } } if (this._rangeCache) { this._invalidateRangeCache(databaseId); } if (this.primaryDatabaseId === databaseId) { this.primaryDatabaseId = null; } } /** * Получение метаданных базы * @param {string} [databaseId] * @returns {DatabaseMetadata} */ async getDatabaseMetadata(databaseId) { const id = databaseId || this.getActiveDatabaseId(); return dataIndexer.getDatabaseMetadata(id); } /** * Получить статистику по загруженному проекту в разрезе current/target. * @param {string} [databaseId] * @returns {ProjectStatsResult|null} */ getProjectStatistics(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return null; const indexes = dataIndexer.getIndexes(id); if (!indexes) return null; const emptyStats = () => ({ wbsCount: 0, activityCount: 0, codeAssignmentCount: 0, resourceAssignmentCount: 0, }); const addStats = (a, b) => ({ wbsCount: a.wbsCount + b.wbsCount, activityCount: a.activityCount + b.activityCount, codeAssignmentCount: a.codeAssignmentCount + b.codeAssignmentCount, resourceAssignmentCount: a.resourceAssignmentCount + b.resourceAssignmentCount, }); /** @type {ProjectStatsResult} */ const result = { databaseName: indexes.name || id, projects: [], total: emptyStats(), }; const projects = Array.from(indexes.projectsById.values()); const hasProjects = projects.length > 0; // Activity ids grouped by projectId and scheduleType /** @type {Map<number, Map<'current'|'target', Set<number>>>} */ const activityIdsByProject = new Map(); for (const scheduleType of ["current", "target"]) { const bySchedule = indexes.activitiesBySchedule.get(scheduleType); if (!bySchedule) continue; for (const activity of bySchedule.values()) { const projectId = activity.projectId ?? 0; if (!activityIdsByProject.has(projectId)) { activityIdsByProject.set(projectId, new Map()); } const byProject = activityIdsByProject.get(projectId); if (!byProject.has(scheduleType)) { byProject.set(scheduleType, new Set()); } byProject.get(scheduleType).add(activity.id); } } const allProjectIds = new Set([ ...projects.map((p) => Number(p.projectId) || 0), ...activityIdsByProject.keys(), ]); for (const projectId of allProjectIds) { const project = indexes.projectsById.get(projectId); const bySchedule = activityIdsByProject.get(projectId) || new Map(); /** @type {ProjectStatsItem} */ const item = { projectId: project?.projectId || String(projectId), name: project?.name || `Проект ${projectId}`, current: emptyStats(), target: emptyStats(), }; for (const scheduleType of ["current", "target"]) { const activityIds = bySchedule.get(scheduleType) || new Set(); const stats = emptyStats(); // WBS count for project + scheduleType for (const wbs of indexes.wbsById.values()) { if ( (wbs.projectId === projectId || (!hasProjects && projectId === 0)) && wbs.scheduleType === scheduleType ) { stats.wbsCount++; } } // Activity count stats.activityCount = activityIds.size; // Code assignments count for (const activityId of activityIds) { const typeMap = indexes.activityCodesByType.get(activityId); if (typeMap) { stats.codeAssignmentCount += typeMap.size; } } // Resource assignments count for (const activityId of activityIds) { const assignments = indexes.assignmentsByActivity.get(activityId); if (assignments) { stats.resourceAssignmentCount += assignments.length; } } item[scheduleType] = stats; result.total = addStats(result.total, stats); } result.projects.push(item); } result.projects.sort((a, b) => String(a.name).localeCompare(String(b.name))); return result; } // ==================== Data Queries ==================== /** * Получение списка проектов * @param {string} [databaseId] * @returns {Promise<Project[]>} */ async getProjects(databaseId) { const id = databaseId || this.getActiveDatabaseId(); return dataServiceJsAdapter.getProjects(id); } /** * Получение работ * @param {Filter[]} [filters] - Фильтры * @param {string} [databaseId] * @param {Object} [options] - Дополнительные опции * @param {'current'|'target'|'both'} [options.scheduleType='both'] - Тип графика * @returns {Promise<Activity[]>} */ async getActivities(filters = [], databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); const { scheduleType = "both", weekRange = null } = options; if (!id) { console.warn("[DataService] No active database"); return []; } return dataServiceJsAdapter.getActivities(id, filters, { scheduleType, weekRange, }); } /** * Получение работ текущего графика (current) * @param {Filter[]} [filters] - Фильтры * @param {string} [databaseId] * @returns {Promise<Activity[]>} */ async getCurrentActivities(filters = [], databaseId) { return this.getActivities(filters, databaseId, { scheduleType: "current" }); } /** * Получение работ целевого графика (target) * @param {Filter[]} [filters] - Фильтры * @param {string} [databaseId] * @returns {Promise<Activity[]>} */ async getTargetActivities(filters = [], databaseId) { return this.getActivities(filters, databaseId, { scheduleType: "target" }); } /** * Сравнение работ между current и target графиками * Возвращает работы, которые есть в обоих графиках, с их различиями * @param {string} [databaseId] * @returns {Promise<Array<Object>>} */ async compareCurrentVsTarget(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) { console.warn("[DataService] No active database"); return []; } const indexes = dataIndexer.getIndexes(id); if (!indexes) return []; const currentByCode = new Map(); const currentActivities = indexes.activitiesBySchedule.get("current") || new Map(); for (const a of currentActivities.values()) { if (a.activityCode) currentByCode.set(a.activityCode, a); } const targetActivities = indexes.activitiesBySchedule.get("target") || new Map(); const results = []; for (const target of targetActivities.values()) { const current = currentByCode.get(target.activityCode); if (!current) continue; results.push({ activityCode: target.activityCode, taskName: current.taskName || target.taskName, currentStartDate: current.startDate, currentEndDate: current.endDate, targetStartDate: target.startDate, targetEndDate: target.endDate, durationDelta: (current.duration || 0) - (target.duration || 0), startDateDelta: this._dateDelta(current.startDate, target.startDate), endDateDelta: this._dateDelta(current.endDate, target.endDate), }); } return results; } /** * @private * @param {string|null} a * @param {string|null} b * @returns {number|null} */ _dateDelta(a, b) { if (!a || !b) return null; const da = new Date(a); const db = new Date(b); if (isNaN(da.getTime()) || isNaN(db.getTime())) return null; return Math.round((da.getTime() - db.getTime()) / (24 * 60 * 60 * 1000)); } /** * Получение работ с кодами * @param {string} [databaseId] * @returns {Promise<Activity[]>} */ async getActivitiesWithCodes(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const indexes = dataIndexer.getIndexes(id); if (!indexes) return []; const results = []; for (const activity of indexes.activitiesById.values()) { const codesByType = indexes.activityCodesByType.get(activity.id); if (!codesByType || codesByType.size === 0) { results.push({ ...activity }); continue; } for (const [typeId, valueId] of codesByType) { const cv = indexes.codeValueById.get(valueId); const ct = indexes.codeTypeById.get(typeId); results.push({ ...activity, codeValueId: cv?.valueId, codeShortName: cv?.shortName, codeDescription: cv?.description, codeColor: cv?.color, codeTypeId: ct?.typeId, codeTypeName: ct?.name, }); } } return results; } /** * Группировка работ по коду * @param {string} codeTypeId - ID типа кода * @param {string} [databaseId] * @param {Object} [options={}] * @param {'current'|'target'|'both'} [options.scheduleType='both'] - Тип графика * @param {{startIdx: number, endIdx: number}|null} [options.weekRange] - Диапазон недель (фильтрация через activity_weeks) * @returns {Promise<GroupedResult[]>} */ async getActivitiesGroupedBy(codeTypeId, databaseId, options = {}) { // Backward compatibility: if databaseId is an object, treat it as options if ( typeof databaseId === "object" && databaseId !== null && !Array.isArray(databaseId) ) { options = databaseId; databaseId = undefined; } const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const indexes = dataIndexer.getIndexes(id); if (!indexes) return []; const typeId = Number(codeTypeId); const { scheduleType = "both", weekRange = null } = options; // Resolve activities filtered by scheduleType and weekRange let activities = dataServiceJsAdapter._getActivitiesBySchedule( indexes, scheduleType, ); if (weekRange) { activities = activities.filter((a) => dataServiceJsAdapter._activityMatchesWeekRange(a, indexes, weekRange), ); } const groups = new Map(); const noCodeItems = []; for (const activity of activities) { const codesByType = indexes.activityCodesByType.get(activity.id); const valueId = codesByType?.get(typeId); if (valueId == null) { noCodeItems.push(activity); continue; } const cv = indexes.codeValueById.get(valueId); const key = String(valueId); if (!groups.has(key)) { groups.set(key, { key, displayName: cv?.shortName || cv?.description || key, color: cv?.color, count: 0, items: [], }); } const group = groups.get(key); group.items.push(activity); group.count = group.items.length; } const results = Array.from(groups.values()); if (noCodeItems.length > 0) { results.push({ key: "no_code", displayName: "No Code", count: noCodeItems.length, items: noCodeItems, }); } return results; } /** * Получение агрегированных метрик для auto-уровня. * Один SQL-запрос вместо N отдельных запросов по каждому code value. * @param {string|number} codeTypeNameOrId — имя типа кода или его ID * @param {string} [databaseId] * @param {Object} [options] * @param {'current'|'target'|'both'} [options.scheduleType='both'] * @param {{startIdx: number, endIdx: number}|null} [options.weekRange] * @param {import('../types.js').Filter[]} [options.filters=[]] — inherited/level фильтры * @param {'codeOrder'|'deviation'|'actual'|'plan'} [options.sortBy='codeOrder'] * @param {'asc'|'desc'} [options.sortDir='asc'] * @returns {Promise<AutoLevelMetric[]>} */ async getAutoLevelMetrics(codeTypeNameOrId, databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const indexes = dataIndexer.getIndexes(id); if (!indexes) return []; console.log( `[DataService] Using JS Data Engine for getAutoLevelMetrics type=${codeTypeNameOrId}`, ); return summaryJsQueryEngine.getAutoLevelMetrics( id, codeTypeNameOrId, options, ); } /** * Получение типов кодов * @param {string} [databaseId] * @param {Object} [options] * @param {'current'|'target'} [options.scheduleType='current'] * @returns {Promise<CodeType[]>} */ async getCodeTypes(databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const { scheduleType = "current" } = options; return dataServiceJsAdapter.getCodeTypes(id, { scheduleType }); } /** * Получение значений кода * @param {string} codeTypeId * @param {string} [databaseId] * @param {Object} [options] * @param {'current'|'target'} [options.scheduleType='current'] * @returns {Promise<CodeValue[]>} */ async getCodeValues(codeTypeId, databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const { scheduleType = "current" } = options; return dataServiceJsAdapter.getCodeValues(id, codeTypeId, { scheduleType }); } /** * Получение WBS * @param {string} [databaseId] * @returns {Promise<WBS[]>} */ /** * Получение WBS * @param {string} [databaseId] * @param {Object} [options={}] * @param {'current'|'target'|'both'} [options.scheduleType='both'] - Тип графика * @returns {Promise<WBS[]>} */ async getWBS(databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return dataServiceJsAdapter.getWBS(id, options); } /** * Получение корневого элемента WBS верхнего уровня (описание проекта) * @param {string} [databaseId] * @param {Object} [options={}] * @param {'current'|'target'} [options.scheduleType='current'] - Тип графика * @returns {Promise<WBS|null>} */ async getRootWBS(databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return null; const { scheduleType = "current" } = options; const indexes = dataIndexer.getIndexes(id); if (!indexes) return null; const rows = Array.from(indexes.wbsById.values()) .filter((w) => w.scheduleType === scheduleType) .filter((w) => w.parentId == null || w.level === 0) .sort((a, b) => { const seqDiff = (a.seqNum || 0) - (b.seqNum || 0); if (seqDiff !== 0) return seqDiff; return (a.wbsId || 0) - (b.wbsId || 0); }); return rows[0] || null; } /** * Получение ресурсов * @param {string} [databaseId] * @returns {Promise<Resource[]>} */ async getResources(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return dataServiceJsAdapter.getResources(id); } /** * Получение назначений ресурсов * @param {string} [databaseId] * @returns {Promise<ResourceAssignment[]>} */ async getResourceAssignments(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const indexes = dataIndexer.getIndexes(id); if (!indexes) return []; return Array.from(indexes.assignmentsById.values()).sort( (a, b) => (a.assignmentId || 0) - (b.assignmentId || 0), ); } /** * Получение списка материальных ресурсов (type = 'material'). * Используется для фильтра в FilterPanel. * @param {string} [databaseId] * @returns {Promise<MaterialResourceItem[]>} */ async getMaterialResources(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return dataServiceJsAdapter.getMaterialResources(id); } /** * Получение детальных данных по физическим объёмам материалов. * Агрегация на уровне ResourceAssignment (не Activity). * SMR-фильтр unconditional. * @param {string} [databaseId] * @param {MaterialVolumeQueryOptions} [options={}] * @returns {Promise<MaterialVolumeItem[]>} */ async getMaterialVolumes(databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return wbsTableDataProvider.getMaterialVolumes(id, options); } // ============================================================================ // Material Deliveries Integration (Phase 4) // ============================================================================ /** * Импортировать поставки материалов из Excel-файла. * @param {File|Blob} file * @param {string} [databaseId] * @param {MaterialDeliveryImportOptions} [options] * @returns {Promise<MaterialDeliveryImportResult>} */ async importMaterialDeliveriesFromExcel(file, databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) { throw new Error("No active database for material delivery import"); } return materialDeliveryService.importFromExcel(file, id, options); } /** * Получить все поставки материалов для базы. * @param {string} [databaseId] * @param {{matchedOnly?: boolean}} [options] * @returns {MaterialDelivery[]} */ getMaterialDeliveries(databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return materialDeliveryService.getDeliveries(id, options); } /** * Получить поставки материалов по ID работы. * @param {number} activityId * @param {string} [databaseId] * @returns {MaterialDelivery[]} */ getMaterialDeliveriesByActivity(activityId, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return materialDeliveryService.getDeliveriesByActivity(activityId, id); } /** * Получить поставки материалов по ID ресурса. * @param {number} resourceId * @param {string} [databaseId] * @returns {MaterialDelivery[]} */ getMaterialDeliveriesByResource(resourceId, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return materialDeliveryService.getDeliveriesByResource(resourceId, id); } /** * Получить поставки материалов по коду МТР. * @param {string} materialCode * @param {string} [databaseId] * @returns {MaterialDelivery[]} */ getMaterialDeliveriesByMaterialCode(materialCode, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return materialDeliveryService.getDeliveriesByMaterialCode(materialCode, id); } /** * Удалить все поставки материалов из базы. * @param {string} [databaseId] */ clearMaterialDeliveries(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return; materialDeliveryService.clearDeliveries(id); } /** * Получить все источники реестров поставок материалов для базы. * @param {string} [databaseId] * @returns {import('../types.js').MaterialDeliverySource[]} */ getMaterialDeliverySources(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; return materialDeliveryService.getSources(id); } /** * Получить источник реестра поставок материалов по ID. * @param {string} sourceId * @param {string} [databaseId] * @returns {import('../types.js').MaterialDeliverySource|undefined} */ getMaterialDeliverySource(sourceId, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return undefined; return materialDeliveryService.getSource(id, sourceId); } /** * Удалить источник реестра поставок материалов и все связанные поставки. * @param {string} sourceId * @param {string} [databaseId] * @returns {boolean} */ removeMaterialDeliverySource(sourceId, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return false; return materialDeliveryService.removeSource(id, sourceId); } /** * Есть ли поставки материалов в базе. * @param {string} [databaseId] * @returns {boolean} */ hasMaterialDeliveries(databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return false; return materialDeliveryService.hasDeliveries(id); } /** * Выполнение произвольного SQL * @param {string} sql * @param {any[]} [params] * @param {string} [databaseId] * @returns {Object[]} */ async executeSQL(sql, params = [], databaseId) { throw new Error( "SQL execution is not supported. JS Data Engine is the only engine.", ); } /** * Получить накопительные трудозатраты по работам до указанной недели. * Результат кэшируется на уровне DataService, поэтому множественные * компоненты могут безопасно вызывать метод для одной и той же недели. * * @param {string} weekEndDate - Дата окончания недели (YYYY-MM-DD) * @param {string} [databaseId] * @returns {Promise<{current: Map<string, {actualManhours: number, remainingManhours: number, totalManhours: number}>, target: Map<string, {actualManhours: number, remainingManhours: number, totalManhours: number}>}>} */ async getCumulativeManhoursByWeek(weekEndDate, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return { current: new Map(), target: new Map() }; const cacheKey = `${id}|${weekEndDate}`; if (!this._cumulativeCache) { this._cumulativeCache = new Map(); } if (this._cumulativeCache.has(cacheKey)) { return this._cumulativeCache.get(cacheKey); } const result = await wbsTableDataProvider.getCumulativeManhoursByWeek( weekEndDate, id, ); this._cumulativeCache.set(cacheKey, result); return result; } /** * Получить инкрементальные трудозатраты по работам за диапазон недель. * В отличие от getCumulativeManhoursByWeek (накопительные до точки), * этот метод считает только те spread-периоды, которые попадают * в выбранный диапазон [startIdx, endIdx] включительно. * * Результат кэшируется в _rangeCache с ключом dbId|startIdx|endIdx|manhours. * * @param {number} startIdx - Начальный week_index (включительно) * @param {number} endIdx - Конечный week_index (включительно) * @param {string} [databaseId] * @returns {Promise<{current: Map<string, {actualManhours: number, remainingManhours: number, totalManhours: number}>, target: Map<string, {actualManhours: number, remainingManhours: number, totalManhours: number}>}>} */ async getManhoursByWeekRange(startIdx, endIdx, databaseId) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return { current: new Map(), target: new Map() }; let sIdx = startIdx != null ? Number(startIdx) : null; let eIdx = endIdx != null ? Number(endIdx) : null; if (sIdx !== null && eIdx !== null) { const min = Math.min(sIdx, eIdx); const max = Math.max(sIdx, eIdx); sIdx = min; eIdx = max; } const cacheKey = `${id}|${sIdx ?? "null"}|${eIdx ?? "null"}|manhours`; if (!this._rangeCache) { this._rangeCache = new Map(); } if (this._rangeCache.has(cacheKey)) { return this._rangeCache.get(cacheKey); } const result = await wbsTableDataProvider.getManhoursByWeekRange( sIdx, eIdx, id, ); this._rangeCache.set(cacheKey, result); return result; } /** * Пересчитывает трудозатраты activities с учётом диапазона недель. * Модифицирует объекты в массиве in-place. * * @param {Activity[]} activities * @param {{startIdx: number|null, endIdx: number|null}} weekRange * @param {string} [databaseId] * @returns {Promise<void>} */ async applyWeekRangeManhours(activities, weekRange, databaseId) { return wbsTableDataProvider.applyWeekRangeManhours( activities, weekRange, databaseId || this.getActiveDatabaseId(), ); } /** * Получить количество активных работ (actual_start_date IS NOT NULL AND actual_end_date IS NULL) per week_index * за указанный диапазон недель. * * Использует junction-таблицу activity_weeks для быстрого поиска. * * @param {number} startIdx - Начальный week_index (включительно) * @param {number} endIdx - Конечный week_index (включительно) * @param {string} [databaseId] * @param {Object} [options] * @param {'current'|'target'|'both'} [options.scheduleType='current'] * @returns {Promise<Array<{weekIndex: number, count: number}>>} */ async getActiveWorksByWeekRange(startIdx, endIdx, databaseId, options = {}) { const id = databaseId || this.getActiveDatabaseId(); if (!id) return []; const sIdx = Math.min(startIdx, endIdx); const eIdx = Math.max(startIdx, endIdx); const { scheduleType = "current" } = options; const cacheKey = `${id}|${sIdx}|${eIdx}|activeWorks|${scheduleType}`; if (!this._rangeCache) { this._rangeCache = new Map(); } if (this._rangeCache.has(cacheKey)) { return this._rangeCache.get(cacheKey); } const indexes = dataIndexer.getIndexes(id); if (!indexes) { this._rangeCache.set(cacheKey, []); return []; } const activities = dataServiceJsAdapter._getActivitiesBySchedule( indexes, scheduleType, ); const weeks = weekIndexService.getWeekRange(id, sIdx, eIdx); const result = []; for (const week of weeks) { let count = 0; for (const a of activities) { if (!a.actualStartDate || a.actualEndDate) continue; if (a.actualStartDate <= week.weekEnd) { count++; } } result.push({ weekIndex: week.weekIndex, count }); } this._rangeCache.set(cacheKey, result); return result; } /** * Инвалидировать range-кэш для указанной базы данных. * Вызывается при изменении данных в БД (импорт, синхронизация). * @private * @param {string} databaseId */ _invalidateRangeCache(databaseId) { if (!this._rangeCache) return; const prefix = `${databaseId}|`; for (const key of this._rangeCache.keys()) { if (key.startsWith(prefix)) { this._rangeCache.delete(key); } } } // ==================== Comparison ==================== /** * Сравнение баз данных * @param {string[]} databaseIds * @param {ComparisonMetric} metric * @returns {Promise<ComparisonResult[]>} */ async compareDatabases(databaseIds, metric) { throw new Error( "compareDatabases is not supported. JS Data Engine is the only engine.", ); } // ==================== Private Methods ==================== /** * Локализует дату из UTC в UTC+3 (Москва). * Primavera API возвращает даты в часовом поясе UTC. * @private * @param {string|number|null|undefined} dateValue * @returns {string|null} */ _localizePrimaveraDate(dateValue) { if ( dateValue === null || dateValue === undefined || dateValue === "" || dateValue === "null" || dateValue === "undefined" ) { return null; } if (typeof dateValue === "number") { const date = new Date(dateValue + 3 * 60 * 60 * 1000); return isNaN(date.getTime()) ? null : date.toISOString(); } const dateStr = String(dateValue).trim(); if (!dateStr) return null; // Если строка не содержит timezone — считаем что это UTC let utcDateStr = dateStr; if (!dateStr.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(dateStr)) { utcDateStr = dateStr + "Z"; } const date = new Date(utcDateStr); if (isNaN(date.getTime())) { return dateStr; } // Добавляем 3 часа (UTC+3) date.setTime(date.getTime() + 3 * 60 * 60 * 1000); return date.toISOString(); } /** * @private */ _ensureInitialized() { if (!this.isInitialized) { throw new Error("DataService not initialized. Call initialize() first."); } } /** * @private * @param {string[]} projectIds * @param {string} authToken * @param {Function} [progressCallback] - Callback для обновления прогресса (percent, phase) * @returns {Promise<Object>} */ async _fetchAllDataFromAPI(projectIds, authToken, progressCallback) { const baseUrl = config.USE_LOCAL_API ? config.API_CONFIG.local.SQL : config.API_CONFIG.production.SQL; // Всего 10 параллельных запросов + 1 для spread данных const totalRequests = 10; let completedRequests = 0; // Функция для отслеживания прогресса отдельных запросов const trackProgress = (promise, label) => { return promise.then((result) => { completedRequests++; // Прогресс от 20% до 70% для этапа загрузки данных const percent = 20 + Math.round((completedRequests / totalRequests) * 50); progressCallback?.(percent, `fetch_${label}`); return result; }); }; // Параллельно получаем базовые данные с отслеживанием прогресса const [ projects, activities, codeTypes, codeValues, codeAssignments, wbs, resources, resourceAssignments, relationships, ] = await Promise.all([ trackProgress( this._fetchProjects(authToken, baseUrl, projectIds), "projects", ), trackProgress( this._fetchActivities(projectIds, authToken, baseUrl), "activities", ), trackProgress( this._fetchCodeTypes(authToken, baseUrl, projectIds), "codeTypes", ), trackProgress( this._fetchAllCodeValues(authToken, baseUrl, projectIds), "codeValues", ), trackProgress( this._fetchCodeAssignments(projectIds, authToken, baseUrl), "codeAssignments", ), trackProgress(this._fetchWBS(projectIds, authToken, baseUrl), "wbs"), trackProgress( this._fetchResources(authToken, baseUrl, projectIds), "resources", ), trackProgress( this._fetchResourceAssignments(projectIds, authToken, baseUrl), "resourceAssignments", ), trackProgress( this._fetchRelationships(projectIds, authToken, baseUrl), "relationships", ), ]); // Загружаем распределение ресурсов по периодам (только для assignment существующих в проекте) // Не у всех работ есть назначения ресурсов, поэтому проверяем наличие let resourceAssignmentSpread = []; if (resourceAssignments && resourceAssignments.length > 0) { const assignmentIds = resourceAssignments .map((ra) => ra.assignmentId) .filter((id) => id !== null && id !== undefined); if (assignmentIds.length > 0) { console.log( `[DataService] Fetching spread data for ${assignmentIds.length} resource assignments...`, ); try { resourceAssignmentSpread = await this._fetchResourceAssignmentSpreadData( assignmentIds, "Week", ["ActualUnits", "RemainingUnits"], authToken, ); completedRequests++; const percent = 20 + Math.round((completedRequests / totalRequests) * 50); progressCallback?.(percent, "fetch_spread"); console.log( `[DataService] Loaded ${resourceAssignmentSpread.length} resource assignment spread records`, ); } catch (error) { console.warn( "[DataService] Failed to fetch resource assignment spread data:", error, ); // Продолжаем без spread данных - не критичная ошибка resourceAssignmentSpread = []; } } else { console.log( "[DataService] No valid assignment IDs found, skipping spread data fetch", ); } } else { console.log( "[DataService] No resource assignments found, skipping spread data fetch", ); } // Загружаем рабочие календари (SYS-025) после основных данных: // один запрос на пакет проектов, диапазон дат — от старта проектов до max(endDate)+1 год let calendars = []; try { const { dateFrom, dateTo } = this._computeCalendarDateRange( projects, activities, ); if (dateFrom && dateTo) { calendars = await this._fetchCalendars( projectIds, authToken, baseUrl, dateFrom, dateTo, ); console.log( `[DataService] Fetched ${calendars.length} calendars (${dateFrom}..${dateTo}).`, ); } else { console.warn( "[DataService] Не удалось определить диапазон дат проектов — календари не загружены", ); } } catch (err) { console.warn( "[DataService] Calendars fetch failed (non-critical):", err.message, ); calendars = []; } // Загружаем единицы измерения отдельно (не критично) let umeasures = []; try { umeasures = await this._fetchUMeasures(authToken, baseUrl); } catch (err) { console.warn( "[DataService] UMeasures fetch failed (non-critical):", err.message, ); } return { projects, activities, codeTypes, codeValues, codeAssignments, wbs, resources, resourceAssignments, resourceAssignmentSpread, relationships, umeasures, calendars, }; } /** * @private * @param {string[]} projectIds - Массив PROJ_ID для фильтрации */ async _fetchProjects(authToken, baseUrl, projectIds) { // Фильтруем по конкретным projectIds, иначе загружаются ВСЕ проекты из Primavera if (!projectIds || projectIds.length === 0) { console.warn("[DataService] No project IDs provided for projects fetch"); return []; } const projectList = projectIds.join(","); const sql = `SELECT PROJ_ID as projectId, PROJ_SHORT_NAME as shortName, PROJ_SHORT_NAME as name, DESCRIPTION as description, PLAN_START_DATE as startDate, PLAN_END_DATE as endDate, LAST_RECALC_DATE as dataDate FROM ADMUSER.PROJECT WHERE PROJ_ID IN (${projectList}) AND DELETE_SESSION_ID IS NULL`; const rows = await this._executePrimaveraQuery(sql, authToken, baseUrl); // Добавляем name как копию shortName (т.к. API возвращает одно поле PROJ_SHORT_NAME) return rows.map((row) => ({ ...row, name: row.shortName, })); } /** * @private */ async _fetchActivities(projectIds, authToken, baseUrl) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for activities fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); const sql = `SELECT TASK_ID as id, PROJ_ID as projectId, WBS_ID as wbsId, TASK.CLNDR_ID as calendarId, TASK_CODE as activityCode, TASK_NAME as taskName, TASK_TYPE as taskType, CASE WHEN (SELECT DAY_HR_CNT FROM ADMUSER.CALENDAR WHERE CLNDR_ID = TASK.CLNDR_ID) > 0 THEN TARGET_DRTN_HR_CNT / (SELECT DAY_HR_CNT FROM ADMUSER.CALENDAR WHERE CLNDR_ID = TASK.CLNDR_ID) ELSE TARGET_DRTN_HR_CNT / 8.0 END as duration, CASE WHEN (SELECT DAY_HR_CNT FROM ADMUSER.CALENDAR WHERE CLNDR_ID = TASK.CLNDR_ID) > 0 THEN ROUND(TOTAL_FLOAT_HR_CNT / (SELECT DAY_HR_CNT FROM ADMUSER.CALENDAR WHERE CLNDR_ID = TASK.CLNDR_ID)) ELSE ROUND(TOTAL_FLOAT_HR_CNT / 8.0) END as totalFloat, CASE WHEN (SELECT DAY_HR_CNT FROM ADMUSER.CALENDAR WHERE CLNDR_ID = TASK.CLNDR_ID) > 0 THEN ROUND(FREE_FLOAT_HR_CNT / (SELECT DAY_HR_CNT FROM ADMUSER.CALENDAR WHERE CLNDR_ID = TASK.CLNDR_ID)) ELSE ROUND(FREE_FLOAT_HR_CNT / 8.0) END as freeFloat, EARLY_START_DATE as startDate, EARLY_END_DATE as endDate, REEND_DATE as reEndDate, ACT_START_DATE as actualStartDate, ACT_END_DATE as actualEndDate, CSTR_TYPE as cstrType, CSTR_DATE as cstrDate, CSTR_TYPE2 as cstrType2, CSTR_DATE2 as cstrDate2 FROM ADMUSER.TASK WHERE PROJ_ID IN (${projectList}) AND DELETE_SESSION_ID IS NULL`; const rows = await this._executePrimaveraQuery(sql, authToken, baseUrl); // Fallback для работ, начатых/завершённых по факту: у них EARLY_* даты // пустые (даты уходят в ACT_*), поэтому подставляем фактические. return rows.map((row) => ({ ...row, startDate: row.startDate || row.actualStartDate, endDate: row.endDate || row.actualEndDate, })); } /** * Загружает рабочие календари, реально используемые работами проектов (SYS-025). * Два запроса: справочник CALENDAR (EXISTS по TASK) + подневная CALENDARX * (только рабочие дни WORKDAYFLAG='Y' AND TOTALWORKHOURS > 0, в диапазоне дат). * У CALENDARX (кастомная таблица) нет DELETE_SESSION_ID — фильтр не применяется. * @private * @param {string[]} projectIds - Массив PROJ_ID для фильтрации * @param {string} authToken * @param {string} baseUrl * @param {string} dateFrom - Нижняя граница дат (YYYY-MM-DD) * @param {string} dateTo - Верхняя граница дат (YYYY-MM-DD) * @returns {Promise<Object[]>} - [{calendarId, name, dayHrCnt, days: [{date, hours}]}] (дни ASC) */ async _fetchCalendars(projectIds, authToken, baseUrl, dateFrom, dateTo) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for calendars fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); const calendarSql = `SELECT c.CLNDR_ID as calendarId, c.CLNDR_NAME as name, c.DAY_HR_CNT as dayHrCnt FROM ADMUSER.CALENDAR c WHERE c.DELETE_SESSION_ID IS NULL AND EXISTS (SELECT 1 FROM ADMUSER.TASK t WHERE t.CLNDR_ID = c.CLNDR_ID AND t.PROJ_ID IN (${projectList}) AND t.DELETE_SESSION_ID IS NULL)`; const calendarRows = await this._executePrimaveraQuery( calendarSql, authToken, baseUrl, ); if (!calendarRows || calendarRows.length === 0) { return []; } const daysSql = `SELECT x.CLNDR_ID as calendarId, TO_CHAR(x.DAYDATE, 'YYYY-MM-DD') as dayDate, x.TOTALWORKHOURS as workHours FROM ADMUSER.CALENDARX x WHERE x.WORKDAYFLAG = 'Y' AND x.TOTALWORKHOURS > 0 AND x.CLNDR_ID IN (SELECT DISTINCT t.CLNDR_ID FROM ADMUSER.TASK t WHERE t.PROJ_ID IN (${projectList}) AND t.DELETE_SESSION_ID IS NULL) AND x.DAYDATE BETWEEN TO_DATE('${dateFrom}', 'YYYY-MM-DD') AND TO_DATE('${dateTo}', 'YYYY-MM-DD') ORDER BY x.CLNDR_ID, x.DAYDATE`; const dayRows = await this._executePrimaveraQuery( daysSql, authToken, baseUrl, ); // Группировка подневных строк по календарю, дни ASC /** @type {Map<number, {date: string, hours: number}[]>} */ const daysByCalendarId = new Map(); for (const row of dayRows || []) { const calendarId = Number(row.calendarId); if (!Number.isFinite(calendarId)) continue; const hours = Number(row.workHours ?? 0) || 0; if (hours <= 0) continue; const date = String(row.dayDate ?? "").slice(0, 10); if (!date) continue; let days = daysByCalendarId.get(calendarId); if (!days) { days = []; daysByCalendarId.set(calendarId, days); } days.push({ date, hours }); } for (const days of daysByCalendarId.values()) { days.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); } return calendarRows.map((row) => { const calendarId = Number(row.calendarId); return { calendarId, name: String(row.name ?? ""), dayHrCnt: Number(row.dayHrCnt ?? 0) || 0, days: daysByCalendarId.get(calendarId) || [], }; }); } /** * Диапазон дат для выгрузки календарей (SYS-025): * от старта проектов до max(endDate) + 1 год (запас под резервы и отрицательные сдвиги), * в пределах горизонта CALENDARX (2016–2036). * Fallback: если у проектов нет плановых дат (PLAN_START/END_DATE пусты), * диапазон выводится из дат работ. * @private * @param {Object[]} projects * @param {Object[]} [activities] * @returns {{dateFrom: string|null, dateTo: string|null}} */ _computeCalendarDateRange(projects, activities) { /** @type {string|null} */ let minStart = null; /** @type {string|null} */ let maxEnd = null; const scan = (rows) => { for (const row of rows || []) { const start = String(row?.startDate ?? "").slice(0, 10); const end = String(row?.endDate ?? "").slice(0, 10); if (/^\d{4}-\d{2}-\d{2}$/.test(start) && (!minStart || start < minStart)) { minStart = start; } if (/^\d{4}-\d{2}-\d{2}$/.test(end) && (!maxEnd || end > maxEnd)) { maxEnd = end; } } }; scan(projects); if (!minStart || !maxEnd) { scan(activities); } if (!minStart || !maxEnd) { return { dateFrom: null, dateTo: null }; } const endDate = new Date(maxEnd + "T00:00:00Z"); endDate.setUTCFullYear(endDate.getUTCFullYear() + 1); let dateFrom = minStart; let dateTo = endDate.toISOString().slice(0, 10); // Горизонт CALENDARX подтверждён владельцем: 2016–2036 if (dateFrom < "2016-01-01") dateFrom = "2016-01-01"; if (dateTo > "2036-12-31") dateTo = "2036-12-31"; return { dateFrom, dateTo }; } /** * @private */ async _fetchCodeTypes(authToken, baseUrl, projectIds) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for code types fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); // Получаем только типы кодов, которые используются в назначениях кода работ данного проекта // (все scope: Global, EPS, Project - главное что код назначен на работы проекта) const sql = `SELECT DISTINCT t.ACTV_CODE_TYPE_ID as typeId, t.ACTV_CODE_TYPE as name, t.ACTV_SHORT_LEN as maxLength, t.ACTV_CODE_TYPE_SCOPE as scope FROM ADMUSER.ACTVTYPE t WHERE t.DELETE_SESSION_ID IS NULL AND EXISTS (SELECT 1 FROM ADMUSER.TASKACTV ta WHERE ta.ACTV_CODE_TYPE_ID = t.ACTV_CODE_TYPE_ID AND ta.PROJ_ID IN (${projectList}) AND ta.DELETE_SESSION_ID IS NULL)`; return this._executePrimaveraQuery(sql, authToken, baseUrl); } /** * @private */ async _fetchAllCodeValues(authToken, baseUrl, projectIds) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for code values fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); // Получаем только значения кодов, которые используются в назначениях кода работ данного проекта const sql = `SELECT DISTINCT v.ACTV_CODE_ID as valueId, v.ACTV_CODE_TYPE_ID as typeId, v.SHORT_NAME as shortName, v.ACTV_CODE_NAME as description, v.COLOR as color, v.SEQ_NUM as sortOrder FROM ADMUSER.ACTVCODE v WHERE v.DELETE_SESSION_ID IS NULL AND EXISTS (SELECT 1 FROM ADMUSER.TASKACTV ta WHERE ta.ACTV_CODE_ID = v.ACTV_CODE_ID AND ta.PROJ_ID IN (${projectList}) AND ta.DELETE_SESSION_ID IS NULL)`; return this._executePrimaveraQuery(sql, authToken, baseUrl); } /** * @private */ async _fetchCodeAssignments(projectIds, authToken, baseUrl) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for code assignments fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); const sql = `SELECT TASK_ID as activityId, ACTV_CODE_TYPE_ID as codeTypeId, ACTV_CODE_ID as codeValueId FROM ADMUSER.TASKACTV WHERE PROJ_ID IN (${projectList}) AND DELETE_SESSION_ID IS NULL`; const rows = await this._executePrimaveraQuery(sql, authToken, baseUrl); console.log( `[DataService] Fetched ${rows.length} code assignments. Sample:`, rows.slice(0, 3), ); return rows; } /** * @private */ async _fetchWBS(projectIds, authToken, baseUrl) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for WBS fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); const sql = `SELECT WBS_ID as wbsId, PROJ_ID as projectId, PARENT_WBS_ID as parentId, SEQ_NUM as seqNum, WBS_SHORT_NAME as wbsShortName, WBS_NAME as wbsName FROM ADMUSER.PROJWBS WHERE PROJ_ID IN (${projectList}) AND DELETE_SESSION_ID IS NULL`; const rows = await this._executePrimaveraQuery(sql, authToken, baseUrl); console.log( `[DataService] Fetched ${rows.length} WBS items. Sample:`, rows.slice(0, 3), ); return rows; } /** * @private */ async _fetchResources(authToken, baseUrl, projectIds) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for resources fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); // Получаем только ресурсы, которые назначены на работы данного проекта. // Убран DISTINCT и CASE — прокси query-to-pv может терять алиасы для вычисляемых выражений. // Преобразование типов и флагов делаем в _importResources на стороне JS. const sql = `SELECT r.RSRC_ID as resourceId, r.RSRC_SHORT_NAME as shortName, NVL(r.RSRC_NAME, r.RSRC_SHORT_NAME) as name, r.RSRC_TYPE as type, r.UNIT_ID as unitId, r.ACTIVE_FLAG as isActive FROM ADMUSER.RSRC r WHERE r.DELETE_SESSION_ID IS NULL AND r.ACTIVE_FLAG = 'Y' AND EXISTS (SELECT 1 FROM ADMUSER.TASKRSRC tr WHERE tr.RSRC_ID = r.RSRC_ID AND tr.PROJ_ID IN (${projectList}) AND tr.DELETE_SESSION_ID IS NULL)`; const rows = await this._executePrimaveraQuery(sql, authToken, baseUrl); console.log( `[DataService] Fetched ${rows.length} resources. Sample:`, rows.slice(0, 3), ); return rows; } /** * Загружает справочник единиц измерения из Primavera. * @private * @param {string} authToken * @param {string} baseUrl * @returns {Promise<Object[]>} */ async _fetchUMeasures(authToken, baseUrl) { const sql = `SELECT UNIT_ID as unitId, UNIT_NAME as unitName, UNIT_ABBREV as unitShortName FROM ADMUSER.UMEASURE`; try { const rows = await this._executePrimaveraQuery(sql, authToken, baseUrl); console.log(`[DataService] Fetched ${rows.length} units of measure.`); return rows; } catch (err) { console.warn( "[DataService] Failed to fetch umeasures (non-critical):", err.message, ); return []; } } /** * @private */ async _fetchResourceAssignments(projectIds, authToken, baseUrl) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for resource assignments fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); const sql = `SELECT TASKRSRC_ID as assignmentId, TASK_ID as activityId, RSRC_ID as resourceId, RSRC_TYPE as resourceType, TARGET_QTY as targetUnits, ACT_REG_QTY as actualUnits, REMAIN_QTY as remainingUnits FROM ADMUSER.TASKRSRC WHERE PROJ_ID IN (${projectList}) AND DELETE_SESSION_ID IS NULL`; return this._executePrimaveraQuery(sql, authToken, baseUrl); } /** * @private */ async _fetchRelationships(projectIds, authToken, baseUrl) { if ( !projectIds || projectIds.length === 0 || projectIds.some((id) => id === undefined || id === null) ) { console.warn( "[DataService] No valid project IDs provided for relationships fetch:", projectIds, ); return []; } const projectList = projectIds.join(","); // Связи между работами (предшественники/последователи) только для работ данного проекта const sql = `SELECT tp.TASK_PRED_ID as relationshipId, tp.TASK_ID as activityId, tp.PRED_TASK_ID as predecessorId, tp.PRED_TYPE as relationshipType, CASE WHEN c.DAY_HR_CNT > 0 THEN tp.LAG_HR_CNT / c.DAY_HR_CNT ELSE tp.LAG_HR_CNT / 8.0 END as lagDays FROM ADMUSER.TASKPRED tp LEFT JOIN ADMUSER.TASK t ON t.TASK_ID = tp.PRED_TASK_ID AND t.DELETE_SESSION_ID IS NULL LEFT JOIN ADMUSER.CALENDAR c ON c.CLNDR_ID = t.CLNDR_ID AND c.DELETE_SESSION_ID IS NULL WHERE tp.PROJ_ID IN (${projectList}) AND tp.DELETE_SESSION_ID IS NULL`; return this._executePrimaveraQuery(sql, authToken, baseUrl); } /** * Загружает распределение ресурсов по периодам через API ReadResourceAssignmentSpread * @private * @param {Array<number>} assignmentIds - Массив ID назначений ресурсов (TASKRSRC_ID) * @param {string} periodType - Тип периода: "Week" или "Month" * @param {Array<string>} spreadFields - Поля для загрузки: ["ActualUnits", "RemainingUnits"] и др. * @param {string} authToken - Токен авторизации * @returns {Promise<Array<Object>>} - Массив объектов с распределением по периодам */ async _fetchResourceAssignmentSpreadData( assignmentIds, periodType, spreadFields, authToken, ) { if (!assignmentIds || assignmentIds.length === 0) { return []; } const spreadUrl = config.USE_LOCAL_API ? config.API_CONFIG.local.RESOURCE_ASSIGNMENT_SPREAD : config.API_CONFIG.production.RESOURCE_ASSIGNMENT_SPREAD; console.log( `[DataService] _fetchResourceAssignmentSpreadData START: url=${spreadUrl}, assignments=${assignmentIds.length}, periodType=${periodType}, fields=${JSON.stringify(spreadFields)}`, ); console.log( `[DataService] Sample assignment IDs (first 5):`, assignmentIds.slice(0, 5), ); // API Primavera имеет ограничение на размер запроса. // Для больших проектов (>20k назначений) один запрос вызывает 500 Internal Server Error. // Разбиваем на пачки по 1000 на стороне клиента. const BATCH_SIZE = 1000; const allResults = []; for (let i = 0; i < assignmentIds.length; i += BATCH_SIZE) { const batch = assignmentIds.slice(i, i + BATCH_SIZE); const requestBody = { PeriodType: periodType, ResourceAssignmentObjectId: batch, SpreadField: spreadFields, }; console.log( `[DataService] Fetching resource assignment spread batch ${i / BATCH_SIZE + 1}/${Math.ceil(assignmentIds.length / BATCH_SIZE)} (${batch.length} assignments)`, ); console.log( `[DataService] Request body preview:`, JSON.stringify(requestBody).substring(0, 300), ); try { const response = await fetchWithAuth(spreadUrl, { method: "POST", body: JSON.stringify(requestBody), }); if (!response.ok) { const errorText = await response.text(); console.warn( `[DataService] Spread API HTTP Error for batch ${i / BATCH_SIZE + 1}:`, response.status, errorText, ); // Продолжаем со следующей пачкой, не прерываем весь процесс continue; } const data = await response.json(); // API возвращает массив объектов с полями: ResourceAssignmentObjectId, Period[] if (Array.isArray(data)) { console.log( `[DataService] Batch ${i / BATCH_SIZE + 1} returned ${data.length} items`, ); if (data.length > 0) { console.log(`[DataService] First item keys:`, Object.keys(data[0])); console.log( `[DataService] First item sample:`, JSON.stringify(data[0]).substring(0, 300), ); } // Локализуем даты периодов (UTC -> UTC+3) const localizedData = data.map((item) => { const periods = item.Period ?? item.period ?? []; if (Array.isArray(periods)) { item.Period = periods.map((period) => { if (!period) return period; return { ...period, StartDate: period.StartDate !== undefined ? this._localizePrimaveraDate(period.StartDate) : period.StartDate, EndDate: period.EndDate !== undefined ? this._localizePrimaveraDate(period.EndDate) : period.EndDate, startDate: period.startDate !== undefined ? this._localizePrimaveraDate(period.startDate) : period.startDate, endDate: period.endDate !== undefined ? this._localizePrimaveraDate(period.endDate) : period.endDate, }; }); } return item; }); allResults.push(...localizedData); } else { console.warn( `[DataService] Spread API returned non-array data for batch ${i / BATCH_SIZE + 1}:`, typeof data, data, ); } } catch (error) { console.warn( `[DataService] Error fetching spread batch ${i / BATCH_SIZE + 1}:`, error, ); // Продолжаем со следующей пачкой } } console.log( `[DataService] Total resource assignment spread records fetched: ${allResults.length}`, ); if (allResults.length > 0) { console.log( `[DataService] Sample spread record keys:`, Object.keys(allResults[0]), ); } return allResults; } /** * @private */ async _executePrimaveraQuery(sql, _authToken, baseUrl, normalize = true) { try { console.log("[DataService] Full SQL:", sql); const response = await fetchWithAuth(baseUrl, { method: "POST", body: JSON.stringify({ sql }), }); if (!response.ok) { const errorText = await response.text(); console.error("[DataService] HTTP Error:", response.status, errorText); throw new Error(`HTTP ${response.status}: ${errorText}`); } const data = await response.json(); console.log("[DataService] Response received, parsing..."); // API возвращает [rows, metadata], возвращаем только rows let rows; if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) { rows = data[0]; } else { rows = Array.isArray(data) ? data : []; } console.log("[DataService] Raw rows count:", rows.length); if (rows.length > 0) { console.log("[DataService] First row keys:", Object.keys(rows[0])); } // Нормализуем имена полей (API возвращает UPPER_CASE вместо camelCase алиасов) if (normalize) { const normalized = this._normalizeFieldNames(rows); console.log("[DataService] Normalized first row:", normalized[0]); return normalized; } return rows; } catch (error) { console.error("[DataService] API query failed:", error); throw error; // Пробрасываем ошибку выше для правильной обработки } } /** * Нормализация имен полей из UPPER_CASE в camelCase * API возвращает оригинальные имена колонок, а не алиасы из SQL * @private * @param {Object[]} rows * @returns {Object[]} */ _normalizeFieldNames(rows) { if (!Array.isArray(rows) || rows.length === 0) { return rows; } // Маппинг UPPER_CASE -> camelCase // Важно: API возвращает оригинальные имена колонок, а не алиасы SQL! // API может возвращать поля как с подчёркиванием (TASK_ID), так и без (TASKID) const fieldMapping = { // Projects PROJ_ID: "projectId", PROJID: "projectId", PROJECTID: "projectId", PROJ_SHORT_NAME: "shortName", PROJSHORTNAME: "shortName", DESCRIPTION: "description", NAME: "name", PLAN_START_DATE: "startDate", PLANSTARTDATE: "startDate", PLAN_END_DATE: "endDate", PLANENDDATE: "endDate", LAST_RECALC_DATE: "dataDate", LASTRECALCDATE: "dataDate", DATADATE: "dataDate", // Activities (TASK) TASK_ID: "id", TASKID: "id", ID: "id", WBS_ID: "wbsId", WBSID: "wbsId", TASK_CODE: "activityCode", TASKCODE: "activityCode", ACTIVITYCODE: "activityCode", TASK_NAME: "taskName", TASKNAME: "taskName", TASK_TYPE: "taskType", TASKTYPE: "taskType", TARGET_DRTN_HR_CNT: "duration", TARGETDRTNHRCNT: "duration", DURATION: "duration", TOTAL_FLOAT_HR_CNT: "totalFloat", TOTALFLOATHRCNT: "totalFloat", TOTALFLOAT: "totalFloat", FREE_FLOAT_HR_CNT: "freeFloat", FREEFLOATHRCNT: "freeFloat", FREEFLOAT: "freeFloat", EARLY_START_DATE: "startDate", EARLYSTARTDATE: "startDate", TARGET_START_DATE: "startDate", TARGETSTARTDATE: "startDate", STARTDATE: "startDate", EARLY_END_DATE: "endDate", EARLYENDDATE: "endDate", TARGET_END_DATE: "endDate", TARGETENDDATE: "endDate", ENDDATE: "endDate", REEND_DATE: "reEndDate", REENDDATE: "reEndDate", REMAININGENDDATE: "reEndDate", ACT_START_DATE: "actualStartDate", ACTSTARTDATE: "actualStartDate", ACTUALSTARTDATE: "actualStartDate", ACTUAL_START_DATE: "actualStartDate", ACT_END_DATE: "actualEndDate", ACTENDDATE: "actualEndDate", ACTUALENDDATE: "actualEndDate", ACTUAL_END_DATE: "actualEndDate", CSTR_TYPE: "cstrType", CSTRTYPE: "cstrType", CSTR_DATE: "cstrDate", CSTRDATE: "cstrDate", CSTR_TYPE2: "cstrType2", CSTRTYPE2: "cstrType2", CSTR_DATE2: "cstrDate2", CSTRDATE2: "cstrDate2", // Calendar (TASK.CLNDR_ID) CLNDR_ID: "calendarId", CLNDRID: "calendarId", CALENDARID: "calendarId", // Calendar (CALENDAR) DAY_HR_CNT: "dayHrCnt", DAYHRCNT: "dayHrCnt", // Code Types (ACTVTYPE) ACTV_CODE_TYPE_ID: "typeId", ACTVCODETYPEID: "typeId", TYPEID: "typeId", ACTV_CODE_TYPE: "name", ACTVCODETYPE: "name", ACTV_SHORT_LEN: "maxLength", ACTVSHORTLEN: "maxLength", ACTV_CODE_TYPE_SCOPE: "scope", ACTVCODETYPESCOPE: "scope", // Code Values (ACTVCODE) ACTV_CODE_ID: "valueId", ACTVCODEID: "valueId", VALUEID: "valueId", ACTV_CODE_NAME: "description", ACTVCODENAME: "description", DESCRIPTION: "description", SHORT_NAME: "shortName", SHORTNAME: "shortName", COLOR: "color", SEQ_NUM: "sortOrder", SEQNUM: "sortOrder", SORTORDER: "sortOrder", // Code Assignments (TASKACTV) — маппим в codeTypeId/codeValueId напрямую ACTV_CODE_TYPE_ID: "codeTypeId", ACTVCODETYPEID: "codeTypeId", CODETYPEID: "codeTypeId", ACTV_CODE_ID: "codeValueId", ACTVCODEID: "codeValueId", CODEVALUEID: "codeValueId", // WBS (PROJWBS) WBS_SHORT_NAME: "wbsShortName", WBSSHORTNAME: "wbsShortName", WBS_NAME: "wbsName", WBSNAME: "wbsName", PARENT_WBS_ID: "parentId", PARENTWBSID: "parentId", PARENTID: "parentId", // Resources (RSRC) RSRC_ID: "resourceId", RSRCID: "resourceId", RSRC_SHORT_NAME: "shortName", RSRCSHORTNAME: "shortName", RSRC_NAME: "name", RSRCNAME: "name", RSRC_TYPE: "type", RSRCTYPE: "type", TYPE: "type", UNIT_ID: "unitId", UNITID: "unitId", UNIT_NAME: "unitName", UNITNAME: "unitName", UNIT_ABBREV: "unitShortName", UNITABBREV: "unitShortName", ACTIVE_FLAG: "isActive", ACTIVEFLAG: "isActive", // Resource Assignments (TASKRSRC) TASKRSRC_ID: "assignmentId", TASKRSRCID: "assignmentId", ASSIGNMENTID: "assignmentId", ACTIVITYID: "activityId", RESOURCEID: "resourceId", TARGET_QTY: "targetUnits", TARGETQTY: "targetUnits", TARGETUNITS: "targetUnits", ACT_REG_QTY: "actualUnits", ACTREGQTY: "actualUnits", ACTUALUNITS: "actualUnits", REMAIN_QTY: "remainingUnits", REMAINQTY: "remainingUnits", REMAININGUNITS: "remainingUnits", // Relationships (TASKPRED) TASK_PRED_ID: "relationshipId", TASKPREDID: "relationshipId", RELATIONSHIPID: "relationshipId", PRED_TASK_ID: "predecessorId", PREDTASKID: "predecessorId", PREDECESSORID: "predecessorId", PRED_TYPE: "relationshipType", PREDTYPE: "relationshipType", RELATIONSHIPTYPE: "relationshipType", LAG_HR_CNT: "lagDays", LAGHRCNT: "lagDays", LAGHOURS: "lagDays", LAGDAYS: "lagDays", LAG_DAYS: "lagDays", }; // Добавляем lowercase варианты (API может возвращать поля в разном регистре) for (const [key, value] of Object.entries(fieldMapping)) { fieldMapping[key.toLowerCase()] = value; } const dateFields = new Set([ "startDate", "endDate", "dataDate", "actualStartDate", "actualEndDate", "reEndDate", "cstrDate", "cstrDate2", ]); return rows.map((row) => { const normalized = {}; for (const [key, value] of Object.entries(row)) { const normalizedKey = fieldMapping[key] || key; normalized[normalizedKey] = dateFields.has(normalizedKey) ? this._localizePrimaveraDate(value) : value; } return normalized; }); } /** * @private * @param {string} eventName * @param {Object} detail */ _dispatchEvent(eventName, detail) { if (typeof document !== "undefined" && document.dispatchEvent) { document.dispatchEvent(new CustomEvent(eventName, { detail })); } } } // Создаем и экспортируем синглтон const dataService = new DataService(); // Делаем доступным глобально для доступа из не-модульных скриптов if (typeof window !== "undefined") { window.DataService = dataService; } export { dataService, DataService }; export default dataService;