/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/ProjectCompareService.js
234 строки
9 KB
Starolat Sergei
feat: модальное окно сравнения двух версий проекта (COMP-034)
24 июл 2026, 08:22
24 июл 2026, 08:22
2d6eeee
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ProjectCompareService — чистая логика сравнения двух версий проекта (COMP-034) * @version 1.0.0 * @see specs/widgets/ProjectCompareModal.md * * Не зависит от DOM — пригоден для Node.js тестов. * Вход: два ParsedDDDB-подобных объекта (activities / resources / resourceAssignments). * * Правила извлечения units (раздел D спеки, утверждено): * - labor (Resource.type 'labor'|'nonlabor'): (actualUnits || 0) + (remainingUnits || 0) * - material (Resource.type 'material'): targetUnits || 0 * * Матчинг работ — по activityCode; назначений внутри работы — по resourceName * (id ресурсов между файлами нестабильны). */ /** * @typedef {import('../types.js').ParsedDDDB} ParsedDDDB * @typedef {import('../types.js').Activity} Activity * @typedef {import('../types.js').Resource} Resource * @typedef {import('../types.js').ResourceAssignment} ResourceAssignment * @typedef {import('../types.js').CompareScheduleMode} CompareScheduleMode * @typedef {import('../types.js').CompareResourceKind} CompareResourceKind * @typedef {import('../types.js').AssignmentDiffRow} AssignmentDiffRow * @typedef {import('../types.js').ActivityDiffRow} ActivityDiffRow * @typedef {import('../types.js').CompareSummary} CompareSummary * @typedef {import('../types.js').ProjectCompareResult} ProjectCompareResult */ /** * Назначение ресурса, агрегированное для сравнения (одна сторона, одна работа). * @typedef {Object} SideAssignment * @property {string} resourceName * @property {CompareResourceKind} kind * @property {number} units */ /** * Определить разрез ресурса. * Фолбек: если ресурс не найден по resourceId — по ResourceAssignment.resourceType. * @param {Resource|undefined} resource * @param {ResourceAssignment} assignment * @returns {CompareResourceKind} */ function resolveKind(resource, assignment) { const type = resource ? resource.type : (assignment.resourceType || ''); return String(type).toLowerCase().includes('mat') ? 'material' : 'labor'; } /** * Извлечь величину назначения по формуле разреза. * @param {ResourceAssignment} assignment * @param {CompareResourceKind} kind * @returns {number} */ function extractUnits(assignment, kind) { if (kind === 'material') { return assignment.targetUnits || 0; } return (assignment.actualUnits || 0) + (assignment.remainingUnits || 0); } /** * Построить индекс одной стороны сравнения: * работы режима по activityCode + назначения по [activityCode][resourceName]. * Несколько назначений одного ресурса на одну работу суммируются. * @param {ParsedDDDB} data * @param {CompareScheduleMode} mode * @returns {{ activitiesByCode: Map<string, Activity>, assignmentsByCode: Map<string, Map<string, SideAssignment>> }} */ function buildSideIndex(data, mode) { /** @type {Map<string, Activity>} */ const activitiesByCode = new Map(); /** @type {Map<number, Activity>} */ const activitiesById = new Map(); const activities = Array.isArray(data.activities) ? data.activities : []; for (const activity of activities) { if (activity.scheduleType !== mode) continue; activitiesById.set(activity.id, activity); const code = activity.activityCode; if (!code) continue; if (!activitiesByCode.has(code)) { activitiesByCode.set(code, activity); } } /** @type {Map<number, Resource>} */ const resourcesById = new Map(); const resources = Array.isArray(data.resources) ? data.resources : []; for (const resource of resources) { resourcesById.set(resource.resourceId, resource); } /** @type {Map<string, Map<string, SideAssignment>>} */ const assignmentsByCode = new Map(); const assignments = Array.isArray(data.resourceAssignments) ? data.resourceAssignments : []; for (const assignment of assignments) { const activity = activitiesById.get(assignment.activityId); if (!activity) continue; const code = activity.activityCode; if (!code) continue; const resource = resourcesById.get(/** @type {number} */ (assignment.resourceId)); const resourceName = resource ? resource.name : `resource-${assignment.resourceId}`; const kind = resolveKind(resource, assignment); const units = extractUnits(assignment, kind); let byResource = assignmentsByCode.get(code); if (!byResource) { byResource = new Map(); assignmentsByCode.set(code, byResource); } const existing = byResource.get(resourceName); if (existing) { existing.units += units; } else { byResource.set(resourceName, { resourceName, kind, units }); } } return { activitiesByCode, assignmentsByCode }; } /** * Сравнить две версии проекта. * @param {ParsedDDDB} dataA - Проект 1 (базовый) * @param {ParsedDDDB} dataB - Проект 2 (сравниваемый) * @param {CompareScheduleMode} mode - Режим: 'current' | 'target' * @param {CompareResourceKind} kind - Разрез: 'labor' | 'material' * @returns {ProjectCompareResult} */ export function computeProjectDiff(dataA, dataB, mode, kind) { const sideA = buildSideIndex(dataA, mode); const sideB = buildSideIndex(dataB, mode); // --- Diff состава работ --- /** @type {ActivityDiffRow[]} */ const activityDiffRows = []; for (const [code, activity] of sideB.activitiesByCode) { if (!sideA.activitiesByCode.has(code)) { activityDiffRows.push({ activityCode: code, activityName: activity.taskName, changeType: 'added' }); } } for (const [code, activity] of sideA.activitiesByCode) { if (!sideB.activitiesByCode.has(code)) { activityDiffRows.push({ activityCode: code, activityName: activity.taskName, changeType: 'removed' }); } } activityDiffRows.sort((a, b) => a.activityCode.localeCompare(b.activityCode)); // --- Diff назначений --- /** @type {AssignmentDiffRow[]} */ const rows = []; const allCodes = new Set([...sideA.activitiesByCode.keys(), ...sideB.activitiesByCode.keys()]); for (const code of allCodes) { const activity = sideA.activitiesByCode.get(code) || sideB.activitiesByCode.get(code); const assignmentsA = sideA.assignmentsByCode.get(code) || new Map(); const assignmentsB = sideB.assignmentsByCode.get(code) || new Map(); const allResources = new Set([...assignmentsA.keys(), ...assignmentsB.keys()]); for (const resourceName of allResources) { const a = assignmentsA.get(resourceName) || null; const b = assignmentsB.get(resourceName) || null; // Разрез определяется по любой из сторон; фильтруем по выбранному kind const rowKind = (a && a.kind) || (b && b.kind) || 'labor'; if (rowKind !== kind) continue; const unitsA = a ? a.units : null; const unitsB = b ? b.units : null; const delta = (unitsB ?? 0) - (unitsA ?? 0); const deltaPercent = unitsA ? (delta / unitsA) * 100 : null; /** @type {AssignmentDiffRow['changeType']} */ let changeType; if (unitsA === null) { changeType = 'added'; } else if (unitsB === null) { changeType = 'removed'; } else if (delta !== 0) { changeType = 'changed'; } else { changeType = 'same'; } rows.push({ activityCode: code, activityName: activity ? activity.taskName : '', resourceKey: resourceName, resourceName, kind, unitsA, unitsB, delta, deltaPercent, changeType, }); } } rows.sort((a, b) => a.activityCode.localeCompare(b.activityCode) || a.resourceName.localeCompare(b.resourceName) ); // --- Сводка --- /** @type {CompareSummary} */ const summary = { activitiesAdded: activityDiffRows.filter((r) => r.changeType === 'added').length, activitiesRemoved: activityDiffRows.filter((r) => r.changeType === 'removed').length, activitiesChanged: 0, assignmentsAdded: 0, assignmentsRemoved: 0, assignmentsChanged: 0, totalDelta: 0, }; /** @type {Set<string>} */ const changedActivities = new Set(); for (const row of rows) { summary.totalDelta += row.delta; if (row.changeType === 'added') summary.assignmentsAdded++; else if (row.changeType === 'removed') summary.assignmentsRemoved++; else if (row.changeType === 'changed') { summary.assignmentsChanged++; changedActivities.add(row.activityCode); } } summary.activitiesChanged = changedActivities.size; return { activityDiffRows, rows, summary }; } export default { computeProjectDiff };