#!/usr/bin/env ruby -w # encoding: UTF-8 # # = NikuReport.rb -- The TaskJuggler III Project Management Software # # Copyright (c) 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014 # by Chris Schlaeger # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. # require 'taskjuggler/AppConfig' require 'taskjuggler/reports/ReportBase' class TaskJuggler class NikuProject attr_reader :name, :id, :tasks, :resources def initialize(id, name) @id = id @name = name @tasks = [] @resources = {} end end class NikuResource attr_reader :id attr_accessor :sum def initialize(id) @id = id @sum = 0.0 end end # The Niku report can be used to export resource allocation data for certain # task groups in the Niku XOG format. This file can be read by the Clarity # enterprise resource management software from Computer Associates. # Since I don't think this is a use case for many users, the implementation # is somewhat of a hack. The report relies on 3 custom attributes that the # user has to define in the project. # Resources must be tagged with a ClarityRID and Tasks must have a # ClarityPID and a ClarityPName. # This file format works for our Clarity installation. I have no idea if it # is even portable to other Clarity installations. class NikuReport < ReportBase def initialize(report) super(report) # A Hash to store NikuProject objects by id @projects = {} # A Hash to map ClarityRID to Resource @resources = {} # Unallocated and vacation time during the report period for all # resources hashed by ClarityId. Values are in days. @resourcesFreeWork = {} # Resources total effort during the report period hashed by ClarityId @resourcesTotalEffort = {} @scenarioIdx = nil end def generateIntermediateFormat super @scenarioIdx = a('scenarios')[0] computeResourceTotals collectProjects computeProjectAllocations end def to_html tableFrame = generateHtmlTableFrame tableFrame << (tr = XMLElement.new('tr')) tr << (td = XMLElement.new('td')) td << (table = XMLElement.new('table', 'class' => 'tj_table', 'cellspacing' => '1')) # Table Header with two rows. First the project name, then the ID. table << (thead = XMLElement.new('thead')) thead << (tr = XMLElement.new('tr', 'class' => 'tabline')) # First line tr << htmlTabCell('Project', true, 'right') @projects.keys.sort.each do |projectId| # Don't include projects without allocations. next if projectTotal(projectId) <= 0.0 name = @projects[projectId].name # To avoid exploding tables for long project names, we only show the # last 15 characters for those. We expect the last characters to be # more significant in those names than the first. name = '...' + name[-15..-1] if name.length > 15 tr << htmlTabCell(name, true, 'center') end tr << htmlTabCell('', true) # Second line thead << (tr = XMLElement.new('tr', 'class' => 'tabline')) tr << htmlTabCell('Resource', true, 'left') @projects.keys.sort.each do |projectId| # Don't include projects without allocations. next if projectTotal(projectId) <= 0.0 tr << htmlTabCell(projectId, true, 'center') end tr << htmlTabCell('Total', true, 'center') # The actual content. One line per resource. table << (tbody = XMLElement.new('tbody')) numberFormat = a('numberFormat') @resourcesTotalEffort.keys.sort.each do |resourceId| tbody << (tr = XMLElement.new('tr', 'class' => 'tabline')) tr << htmlTabCell("#{@resources[resourceId].name} (#{resourceId})", true, 'left') @projects.keys.sort.each do |projectId| next if projectTotal(projectId) <= 0.0 value = sum(projectId, resourceId) valStr = numberFormat.format(value) valStr = '' if valStr.to_f == 0.0 tr << htmlTabCell(valStr) end tr << htmlTabCell(numberFormat.format(resourceTotal(resourceId)), true) end # Project totals tbody << (tr = XMLElement.new('tr', 'class' => 'tabline')) tr << htmlTabCell('Total', 'true', 'left') @projects.keys.sort.each do |projectId| next if (pTotal = projectTotal(projectId)) <= 0.0 tr << htmlTabCell(numberFormat.format(pTotal), true, 'right') end tr << htmlTabCell(numberFormat.format(total()), true, 'right') tableFrame end def to_niku xml = XMLDocument.new xml << XMLComment.new(<<"EOT" Generated by #{AppConfig.softwareName} v#{AppConfig.version} on #{TjTime.new} For more information about #{AppConfig.softwareName} see #{AppConfig.contact}. Project: #{@project['name']} Date: #{@project['now']} EOT ) xml << (nikuDataBus = XMLElement.new('NikuDataBus', 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:noNamespaceSchemaLocation' => '../xsd/nikuxog_project.xsd')) nikuDataBus << XMLElement.new('Header', 'action' => 'write', 'externalSource' => 'NIKU', 'objectType' => 'project', 'version' => '7.5.0') nikuDataBus << (projects = XMLElement.new('Projects')) timeFormat = '%Y-%m-%dT%H:%M:%S' numberFormat = a('numberFormat') @projects.keys.sort.each do |projectId| prj = @projects[projectId] projects << (project = XMLElement.new('Project', 'name' => prj.name, 'projectID' => prj.id)) project << (resources = XMLElement.new('Resources')) # We iterate over all resources to ensure that all have an entry in # the Clarity database for all projects. This is done to work around a # limitation of Clarity with respect to filling time sheets with # assigned projects. @resources.keys.sort.each do |clarityRID| resources << (resource = XMLElement.new('Resource', 'resourceID' => clarityRID, 'defaultAllocation' => '0')) resource << (allocCurve = XMLElement.new('AllocCurve')) sum = sum(prj.id, clarityRID) allocCurve << (XMLElement.new('Segment', 'start' => a('start').to_s(timeFormat), 'finish' => (a('end') - 1).to_s(timeFormat), 'sum' => numberFormat.format(sum).to_s)) end # The custom information section usually contains Clarity installation # specific parts. They are identical for each project section, so we # mis-use the title attribute to insert them as an XML blob. project << XMLBlob.new(a('title')) unless a('title').empty? end xml.to_s end def to_csv table = [] # Header line with project names table << (row = []) # First column is the resource name and ID. row << "" projectIds = @projects.keys.sort projectIds.each do |projectId| row << @projects[projectId].name end # Header line with project IDs table << (row = []) row << "Resource" projectIds.each do |projectId| row << projectId end @resourcesTotalEffort.keys.sort.each do |resourceId| # Add one line per resource. table << (row = []) row << "#{@resources[resourceId].name} (#{resourceId})" projectIds.each do |projectId| row << sum(projectId, resourceId) end end table end private def sum(projectId, resourceId) project = @projects[projectId] return 0.0 unless project resource = project.resources[resourceId] return 0.0 unless resource && @resourcesTotalEffort[resourceId] resource.sum / @resourcesTotalEffort[resourceId] end def resourceTotal(resourceId) total = 0.0 @projects.each_key do |projectId| total += sum(projectId, resourceId) end total end def projectTotal(projectId) total = 0.0 @resources.each_key do |resourceId| total += sum(projectId, resourceId) end total end def total total = 0.0 @projects.each_key do |projectId| @resources.each_key do |resourceId| total += sum(projectId, resourceId) end end total end def htmlTabCell(text, headerCell = false, align = 'right') td = XMLElement.new('td', 'class' => headerCell ? 'tabhead' : 'taskcell1') td << XMLNamedText.new(text, 'div', 'class' => headerCell ? 'headercelldiv' : 'celldiv', 'style' => "text-align:#{align}") td end # The report must contain percent values for the allocation of the # resources. A value of 1.0 means 100%. The resource is fully allocated # for the whole report period. To compute the percentage later on, we # first have to compute the maximum possible allocation. def computeResourceTotals # Prepare the resource list. resourceList = PropertyList.new(@project.resources) resourceList.setSorting(@report.get('sortResources')) resourceList = filterResourceList(resourceList, nil, @report.get('hideResource'), @report.get('rollupResource'), @report.get('openNodes')) # Prepare a template for the Query we will use to get all the data. queryAttrs = { 'project' => @project, 'scopeProperty' => nil, 'scenarioIdx' => @scenarioIdx, 'loadUnit' => a('loadUnit'), 'numberFormat' => a('numberFormat'), 'timeFormat' => a('timeFormat'), 'currencyFormat' => a('currencyFormat'), 'start' => a('start'), 'end' => a('end'), 'journalMode' => a('journalMode'), 'journalAttributes' => a('journalAttributes'), 'sortJournalEntries' => a('sortJournalEntries'), 'costAccount' => a('costaccount'), 'revenueAccount' => a('revenueaccount') } query = Query.new(queryAttrs) # Calculate the number of working days in the report interval. workingDays = @project.workingDays(TimeInterval.new(a('start'), a('end'))) resourceList.each do |resource| # We only care about leaf resources that have the custom attribute # 'ClarityRID' set. next if !resource.leaf? || (resourceId = resource.get('ClarityRID')).nil? || resourceId.empty? query.property = resource # First get the allocated effort. query.attributeId = 'effort' query.process # Effort in resource days total = query.to_num # A fully allocated resource should always have a total of 1.0 per # working day. If the total is larger, we assume unpaid overtime. If # it's less, the resource was either not fully allocated or had less # working hours or was on vacation. if total >= workingDays @resourcesFreeWork[resourceId] = 0.0 else @resourcesFreeWork[resourceId] = workingDays - total total = workingDays end @resources[resourceId] = resource # This is the maximum possible work of this resource in the report # period. @resourcesTotalEffort[resourceId] = total end # Make sure that we have at least one Resource with a ClarityRID. if @resourcesTotalEffort.empty? raise TjException.new, 'No resources with the custom attribute ClarityRID were found!' end end # Search the Task list for the various ClarityPIDs and create a new Task # list for each ClarityPID. def collectProjects # Prepare the task list. taskList = PropertyList.new(@project.tasks) taskList.setSorting(@report.get('sortTasks')) taskList = filterTaskList(taskList, nil, @report.get('hideTask'), @report.get('rollupTask'), @report.get('openNodes')) taskList.each do |task| # We only care about tasks that are leaf tasks and have resource # allocations. next unless task.leaf? || task['assignedresources', @scenarioIdx].empty? id = task.get('ClarityPID') # Ignore tasks without a ClarityPID attribute. next if id.nil? if id.empty? raise TjException.new, "ClarityPID of task #{task.fullId} may not be empty" end name = task.get('ClarityPName') if name.nil? raise TjException.new, "ClarityPName of task #{task.fullId} has not been set!" end if name.empty? raise TjException.new, "ClarityPName of task #{task.fullId} may not be empty!" end if (project = @projects[id]).nil? # We don't have a record for the Clarity project yet, so we create a # new NikuProject object. project = NikuProject.new(id, name) # And store it in the project list hashed by the ClarityPID. @projects[id] = project else # Due to a design flaw in the Niku file format, Clarity projects are # identified by a name and an ID. We have to check that those pairs # are always the same. if (fTask = project.tasks.first).get('ClarityPName') != name raise TjException.new, "Task #{task.fullId} and task #{fTask.fullId} " + "have same ClarityPID (#{id}) but different ClarityPName " + "(#{name}/#{fTask.get('ClarityPName')})" end end # Append the Task to the task list of the Clarity project. project.tasks << task end if @projects.empty? raise TjException.new, 'No tasks with the custom attributes ClarityPID and ClarityPName ' + 'were found!' end # If the user did specify a project ID and name to collect the vacation # time, we'll add this as a project as well. if (id = @report.get('timeOffId')) && (name = @report.get('timeOffName')) @projects[id] = project = NikuProject.new(id, name) @resources.each do |resourceId, resource| project.resources[resourceId] = r = NikuResource.new(resourceId) r.sum = @resourcesFreeWork[resourceId] end end end # Compute the total effort each Resource is allocated to the Task objects # that have the same ClarityPID. def computeProjectAllocations # Prepare a template for the Query we will use to get all the data. queryAttrs = { 'project' => @project, 'scenarioIdx' => @scenarioIdx, 'loadUnit' => a('loadUnit'), 'numberFormat' => a('numberFormat'), 'timeFormat' => a('timeFormat'), 'currencyFormat' => a('currencyFormat'), 'start' => a('start'), 'end' => a('end'), 'journalMode' => a('journalMode'), 'journalAttributes' => a('journalAttributes'), 'sortJournalEntries' => a('sortJournalEntries'), 'costAccount' => a('costaccount'), 'revenueAccount' => a('revenueaccount') } query = Query.new(queryAttrs) timeOffId = @report.get('timeOffId') @projects.each_value do |project| next if project.id == timeOffId project.tasks.each do |task| task['assignedresources', @scenarioIdx].each do |resource| # Only consider resources that are in the filtered resource list. next unless @resources[resource.get('ClarityRID')] query.property = task query.scopeProperty = resource query.attributeId = 'effort' query.process work = query.to_num # If the resource was not actually working on this task during the # report period, we don't create a record for it. next if work <= 0.0 resourceId = resource.get('ClarityRID') if (resourceRecord = project.resources[resourceId]).nil? # If we don't already have a NikuResource object for the # Resource, we create a new one. resourceRecord = NikuResource.new(resourceId) # Store the new NikuResource in the resource list of the # NikuProject record. project.resources[resourceId] = resourceRecord end resourceRecord.sum += query.to_num end end end end end end