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.
# File lib/reports/NikuReport.rb, line 55 55: def initialize(report) 56: super(report) 57: 58: # A Hash to store NikuProject objects by id 59: @projects = {} 60: 61: # A Hash to map ClarityRID to Resource 62: @resources = {} 63: 64: # Resources total effort during the report period hashed by ClarityId 65: @resourcesTotalEffort = {} 66: 67: @scenarioIdx = nil 68: end
# File lib/reports/NikuReport.rb, line 70 70: def generateIntermediateFormat 71: super 72: 73: @scenarioIdx = a('scenarios')[0] 74: 75: computeResourceTotals 76: collectProjects 77: computeProjectAllocations 78: end
# File lib/reports/NikuReport.rb, line 196 196: def to_csv 197: table = [] 198: # Header line with project names 199: table << (row = []) 200: # First column is the resource name and ID. 201: row << "" 202: @projects.each_key do |projectId| 203: row << @projects[projectId].name 204: end 205: 206: # Header line with project IDs 207: table << (row = []) 208: row << "Resource" 209: @projects.each_key do |projectId| 210: row << projectId 211: end 212: 213: @resourcesTotalEffort.each_key do |resourceId| 214: # Add one line per resource. 215: table << (row = []) 216: row << "#{@resources[resourceId].name} (#{resourceId})" 217: @projects.each_key do |projectId| 218: row << sum(projectId, resourceId) 219: end 220: end 221: 222: table 223: end
# File lib/reports/NikuReport.rb, line 80 80: def to_html 81: tableFrame = generateHtmlTableFrame 82: 83: tableFrame << (tr = XMLElement.new('tr')) 84: tr << (td = XMLElement.new('td')) 85: td << (table = XMLElement.new('table', 'class' => 'tj_table', 86: 'cellspacing' => '1')) 87: 88: # Table Header with two rows. First the project name, then the ID. 89: table << (thead = XMLElement.new('thead')) 90: thead << (tr = XMLElement.new('tr', 'class' => 'tabline')) 91: # First line 92: tr << htmlTabCell('Project', true, 'right') 93: @projects.each_key do |projectId| 94: # Don't include projects without allocations. 95: next if projectTotal(projectId) <= 0.0 96: name = @projects[projectId].name 97: # To avoid exploding tables for long project names, we only show the 98: # last 15 characters for those. We expect the last characters to be 99: # more significant in those names than the first. 100: name = '...' + name[15..1] if name.length > 15 101: tr << htmlTabCell(name, true, 'center') 102: end 103: tr << htmlTabCell('', true) 104: # Second line 105: thead << (tr = XMLElement.new('tr', 'class' => 'tabline')) 106: tr << htmlTabCell('Resource', true, 'left') 107: @projects.each_key do |projectId| 108: # Don't include projects without allocations. 109: next if projectTotal(projectId) <= 0.0 110: tr << htmlTabCell(projectId, true, 'center') 111: end 112: tr << htmlTabCell('Total', true, 'center') 113: 114: # The actual content. One line per resource. 115: table << (tbody = XMLElement.new('tbody')) 116: @resourcesTotalEffort.each_key do |resourceId| 117: tbody << (tr = XMLElement.new('tr', 'class' => 'tabline')) 118: tr << htmlTabCell("#{@resources[resourceId].name} (#{resourceId})", 119: true, 'left') 120: 121: @projects.each_key do |projectId| 122: next if projectTotal(projectId) <= 0.0 123: value = sum(projectId, resourceId) 124: tr << htmlTabCell(value >= 0.01 ? format("%.2f", value) : '') 125: end 126: 127: tr << htmlTabCell(format("%.2f", resourceTotal(resourceId)), true) 128: end 129: 130: # Project totals 131: tbody << (tr = XMLElement.new('tr', 'class' => 'tabline')) 132: tr << htmlTabCell('Total', 'true', 'left') 133: @projects.each_key do |projectId| 134: next if projectTotal(projectId) <= 0.0 135: tr << htmlTabCell(format("%.2f", projectTotal(projectId)), true, 136: 'right') 137: end 138: tr << htmlTabCell(format("%.2f", total()), true, 'right') 139: tableFrame 140: end
# File lib/reports/NikuReport.rb, line 142 142: def to_niku 143: xml = XMLDocument.new 144: xml << XMLComment.new(Generated by #{AppConfig.softwareName} v#{AppConfig.version} on #{TjTime.now}For more information about #{AppConfig.softwareName} see #{AppConfig.contact}.Project: #{@project['name']}Date: #{@project['now']} 145: ) 146: xml << (nikuDataBus = 147: XMLElement.new('NikuDataBus', 148: 'xmlns:xsi' => 149: 'http://www.w3.org/2001/XMLSchema-instance', 150: 'xsi:noNamespaceSchemaLocation' => 151: '../xsd/nikuxog_project.xsd')) 152: nikuDataBus << XMLElement.new('Header', 'action' => 'write', 153: 'externalSource' => 'NIKU', 154: 'objectType' => 'project', 155: 'version' => '7.5.0') 156: nikuDataBus << (projects = XMLElement.new('Projects')) 157: 158: timeFormat = '%Y-%m-%dT%H:%M:%S' 159: @projects.each_value do |prj| 160: # Don't include projects with 0 allocations 161: next if projectTotal(prj.id) <= 0.0 162: 163: projects << (project = 164: XMLElement.new('Project', 165: 'name' => prj.name, 166: 'projectID' => prj.id)) 167: project << (resources = XMLElement.new('Resources')) 168: prj.resources.each_value do |res| 169: resources << (resource = 170: XMLElement.new('Resource', 171: 'resourceID' => res.id, 172: 'defaultAllocation' => '0')) 173: resource << (allocCurve = XMLElement.new('AllocCurve')) 174: allocCurve << (XMLElement.new('Segment', 175: 'start' => 176: a('start').to_s(timeFormat), 177: 'finish' => 178: (a('end') - 1).to_s(timeFormat), 179: 'sum' => sum(prj.id, res.id).to_s)) 180: end 181: 182: # The custom information section usually contains Clarity installation 183: # specific parts. They are identical for each project section, so we 184: # mis-use the title attribute to insert them as an XML blob. 185: project << XMLBlob.new(a('title')) unless a('title').empty? 186: end 187: 188: xml.to_s 189: end
Search the Task list for the various ClarityPIDs and create a new Task list for each ClarityPID.
# File lib/reports/NikuReport.rb, line 335 335: def collectProjects 336: # Prepare the task list. 337: taskList = PropertyList.new(@project.tasks) 338: taskList.setSorting(@report.get('sortTasks')) 339: taskList = filterTaskList(taskList, nil, @report.get('hideTask'), 340: @report.get('rollupTask'), @report.get('openNodes')) 341: 342: 343: taskList.each do |task| 344: # We only care about tasks that are leaf tasks and have resource 345: # allocations. 346: next unless task.leaf? || 347: task['assignedresources', @scenarioIdx].empty? 348: 349: id = task.get('ClarityPID') 350: # Ignore tasks without a ClarityPID attribute. 351: next if id.nil? 352: if id.empty? 353: raise TjException.new, 354: "ClarityPID of task #{task.fullId} may not be empty" 355: end 356: 357: name = task.get('ClarityPName') 358: if name.nil? 359: raise TjException.new, 360: "ClarityPName of task #{task.fullId} has not been set!" 361: end 362: if name.empty? 363: raise TjException.new, 364: "ClarityPName of task #{task.fullId} may not be empty!" 365: end 366: 367: if (project = @projects[id]).nil? 368: # We don't have a record for the Clarity project yet, so we create a 369: # new NikuProject object. 370: project = NikuProject.new(id, name) 371: # And store it in the project list hashed by the ClarityPID. 372: @projects[id] = project 373: else 374: # Due to a design flaw in the Niku file format, Clarity projects are 375: # identified by a name and an ID. We have to check that those pairs 376: # are always the same. 377: if (fTask = project.tasks.first).get('ClarityPName') != name 378: raise TjException.new, 379: "Task #{task.fullId} and task #{fTask.fullId} " + 380: "have same ClarityPID (#{id}) but different ClarityPName " + 381: "(#{name}/#{fTask.get('ClarityPName')})" 382: end 383: end 384: # Append the Task to the task list of the Clarity project. 385: project.tasks << task 386: end 387: 388: if @projects.empty? 389: raise TjException.new, 390: 'No tasks with the custom attributes ClarityPID and ClarityPName ' + 391: 'were found!' 392: end 393: end
Compute the total effort each Resource is allocated to the Task objects that have the same ClarityPID.
# File lib/reports/NikuReport.rb, line 397 397: def computeProjectAllocations 398: @projects.each_value do |project| 399: project.tasks.each do |task| 400: task['assignedresources', @scenarioIdx].each do |resource| 401: # Only consider resources that are in the filtered resource list. 402: next unless @resources[resource.get('ClarityRID')] 403: 404: # Prepare a template for the Query we will use to get all the data. 405: queryAttrs = { 'project' => @project, 406: 'property' => task, 407: 'scopeProperty' => resource, 408: 'scenarioIdx' => @scenarioIdx, 409: 'loadUnit' => a('loadUnit'), 410: 'numberFormat' => a('numberFormat'), 411: 'timeFormat' => a('timeFormat'), 412: 'currencyFormat' => a('currencyFormat'), 413: 'start' => a('start'), 'end' => a('end'), 414: 'costAccount' => a('costAccount'), 415: 'revenueAccount' => a('revenueAccount') } 416: 417: query = Query.new(queryAttrs) 418: query.attributeId = 'effort' 419: query.process 420: work = query.to_num 421: 422: # If the resource was not actually working on this task during the 423: # report period, we don't create a record for it. 424: next if work <= 0.0 425: 426: resourceId = resource.get('ClarityRID') 427: if (resourceRecord = project.resources[resourceId]).nil? 428: # If we don't already have a NikuResource object for the 429: # Resource, we create a new one. 430: resourceRecord = NikuResource.new(resourceId) 431: # Store the new NikuResource in the resource list of the 432: # NikuProject record. 433: project.resources[resourceId] = resourceRecord 434: end 435: resourceRecord.sum += query.to_num 436: end 437: end 438: end 439: 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.
# File lib/reports/NikuReport.rb, line 276 276: def computeResourceTotals 277: # Prepare the resource list. 278: resourceList = PropertyList.new(@project.resources) 279: resourceList.setSorting(@report.get('sortResources')) 280: resourceList = filterResourceList(resourceList, nil, 281: @report.get('hideResource'), 282: @report.get('rollupResource'), 283: @report.get('openNodes')) 284: 285: resourceList.each do |resource| 286: # We only care about leaf resources that have the custom attribute 287: # 'ClarityRID' set. 288: next if !resource.leaf? || 289: (resourceId = resource.get('ClarityRID')).nil? || 290: resourceId.empty? 291: 292: # Prepare a template for the Query we will use to get all the data. 293: queryAttrs = { 'project' => @project, 294: 'property' => resource, 295: 'scopeProperty' => nil, 296: 'scenarioIdx' => @scenarioIdx, 297: 'loadUnit' => a('loadUnit'), 298: 'numberFormat' => a('numberFormat'), 299: 'timeFormat' => a('timeFormat'), 300: 'currencyFormat' => a('currencyFormat'), 301: 'start' => a('start'), 'end' => a('end'), 302: 'costAccount' => a('costAccount'), 303: 'revenueAccount' => a('revenueAccount') } 304: 305: query = Query.new(queryAttrs) 306: 307: # First get the allocated effort. 308: query.attributeId = 'effort' 309: query.process 310: total = query.to_num 311: 312: # Then add the still available effort. 313: query.attributeId = 'freework' 314: query.process 315: total += query.to_num 316: 317: next if total <= 0.0 318: 319: @resources[resourceId] = resource 320: 321: # This is the maximum possible work of this resource in the report 322: # period. 323: @resourcesTotalEffort[resourceId] = total 324: end 325: 326: # Make sure that we have at least one Resource with a ClarityRID. 327: if @resourcesTotalEffort.empty? 328: raise TjException.new, 329: 'No resources with the custom attribute ClarityRID were found!' 330: end 331: end
# File lib/reports/NikuReport.rb, line 263 263: def htmlTabCell(text, headerCell = false, align = 'right') 264: td = XMLElement.new('td', 'class' => headerCell ? 'tabhead' : 'taskcell1') 265: td << XMLNamedText.new(text, 'div', 266: 'class' => headerCell ? 'headercelldiv' : 'celldiv', 267: 'style' => "text-align:#{align}") 268: td 269: end
# File lib/reports/NikuReport.rb, line 245 245: def projectTotal(projectId) 246: total = 0.0 247: @resources.each_key do |resourceId| 248: total += sum(projectId, resourceId) 249: end 250: total 251: end
# File lib/reports/NikuReport.rb, line 237 237: def resourceTotal(resourceId) 238: total = 0.0 239: @projects.each_key do |projectId| 240: total += sum(projectId, resourceId) 241: end 242: total 243: end
# File lib/reports/NikuReport.rb, line 227 227: def sum(projectId, resourceId) 228: project = @projects[projectId] 229: return 0.0 unless project 230: 231: resource = project.resources[resourceId] 232: return 0.0 unless resource && @resourcesTotalEffort[resourceId] 233: 234: resource.sum / @resourcesTotalEffort[resourceId] 235: end
Disabled; run with --debug to generate this.
Generated with the Darkfish Rdoc Generator 1.1.6.