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