File: TimeSheets.rb

package info (click to toggle)
tj3 3.8.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 5,048 kB
  • sloc: ruby: 36,481; javascript: 1,113; sh: 19; makefile: 17
file content (404 lines) | stat: -rw-r--r-- 13,616 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
#!/usr/bin/env ruby -w
# encoding: UTF-8
#
# = TimeSheets.rb -- The TaskJuggler III Project Management Software
#
# Copyright (c) 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014
#               by Chris Schlaeger <cs@taskjuggler.org>
#
# 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.
#

class TaskJuggler

  # This class holds the work related bits of a time sheet that are specific
  # to a single Task. This can be an existing Task or a new one identified by
  # it's ID String. For effort based task, it stores the remaining effort, for
  # other task the expected end date. For all tasks it stores the completed
  # work during the reporting time frame.
  class TimeSheetRecord

    include MessageHandler

    attr_reader :task, :work
    attr_accessor :sourceFileInfo, :remaining, :expectedEnd, :status,
                   :priority, :name

    def initialize(timeSheet, task)
      # This is a reference to a Task object for existing tasks or an ID as
      # String for new tasks.
      @task = task
      # Add the new TimeSheetRecord to the TimeSheet it belongs to.
      (@timeSheet = timeSheet) << self
      # Work done will be measured in time slots.
      @work = nil
      # Remaining work will be measured in time slots.
      @remaining = nil
      @expectedEnd = nil
      # For new task, we also need to store the name.
      @name = nil
      # Reference to the JournalEntry object that holds the status for this
      # record.
      @status = nil
      @priority = 0
      @sourceFileInfo = nil
    end

    # Store the number of worked time slots. If the value is an Integer, it can
    # be directly assigned. A Float is interpreted as percentage and must be
    # in the rage of 0.0 to 1.0.
    def work=(value)
      if value.is_a?(Integer)
        @work = value
      else
        # Must be percent value
        @work = @timeSheet.percentToSlots(value)
      end
    end

    # Perform all kinds of consistency checks.
    def check
      scIdx = @timeSheet.scenarioIdx
      taskId = @task.is_a?(Task) ? @task.fullId : @task
      # All TimeSheetRecords must have a 'work' attribute.
      if @work.nil?
        error('ts_no_work',
              "The time sheet record for task #{taskId} must " +
              "have a 'work' attribute to specify how much was done " +
              "for this task during the reported period.")
      end
      if @task.is_a?(Task)
        # This is already known tasks.
        if @task['effort', scIdx] > 0
          unless @remaining
            error('ts_no_remaining',
                  "The time sheet record for task #{taskId} must " +
                  "have a 'remaining' attribute to specify how much " +
                  "effort is left for this task.")
          end
        else
          unless @expectedEnd
            error('ts_no_expected_end',
                  "The time sheet record for task #{taskId} must " +
                  "have an 'end' attribute to specify the expected end " +
                  "of this task.")
          end
        end
      else
        # This is for new tasks.
        if @remaining.nil? && @expectedEnd.nil?
          error('ts_no_rem_or_end',
                "New task #{taskId} requires either a 'remaining' or a " +
                "'end' attribute.")
        end
      end

      if @work >= @timeSheet.daysToSlots(1) && @status.nil?
        error('ts_no_status_work',
              "You must specify a status for task #{taskId}. It was worked " +
              "on for a day or more.")
      end

      if @status
        if @status.headline.empty?
          error('ts_no_headline',
                "You must provide a headline for the status of " +
                "task #{taskId}")
        end
        if @status.summary &&
          @status.summary.richText.inputText == "A summary text\n"
          error('ts_default_summary',
                "You must change the default summary text of the status " +
                "for task #{taskId}.")
        end
        if @status.alertLevel > 0 && @status.summary.nil? &&
           @status.details.nil?
          error('ts_alert1_more_details',
                "Task #{taskId} has an elevated alert level and must " +
                "have a summary or details section.")
        end
        if @status.alertLevel > 1 && @status.details.nil?
          error('ts_alert2_more_details',
                "Task #{taskId} has a high alert level and must have " +
                "a details section.")
        end
      end
    end

    def warnOnDelta(startIdx, endIdx)
      # Ignore personal entries.
      return unless @task

      resource = @timeSheet.resource
      if @task.is_a?(String)
        # A resource has requested a new Task to be created.
        warning('ts_res_new_task',
                "#{resource.name} is requesting a new task:\n" +
                "  ID: #{@task}\n" +
                "  Name: #{@name}\n" +
                "  Work: #{@timeSheet.slotsToDays(@work)}d  " +
                (@remaining ?
                 "Remaining: #{@timeSheet.slotsToDays(@remaining)}d" :
                 "End: #{@end.to_s}"))
        return
      end

      scenarioIdx = @timeSheet.scenarioIdx
      project = resource.project
      plannedWork = @task.getEffectiveWork(scenarioIdx, startIdx, endIdx,
                                           resource)
      # Convert the @work slots into a daily load.
      work = project.convertToDailyLoad(@work * project['scheduleGranularity'])

      if work != plannedWork
        warning('ts_res_work_delta',
                "#{resource.name} worked " +
                "#{work < plannedWork ? 'less' : 'more'} " +
                "on #{@task.fullId}\n" +
                "#{work}d instead of #{plannedWork}d")
      end
      if @task['effort', scenarioIdx] > 0
        startIdx = endIdx
        endIdx = project.dateToIdx(@task['end', scenarioIdx])
        remainingWork = @task.getEffectiveWork(scenarioIdx, startIdx, endIdx,
                                               resource)
        # Convert the @remaining slots into a daily load.
        remaining = project.convertToDailyLoad(@remaining *
                                               project['scheduleGranularity'])
        if remaining != remainingWork
          warning('ts_res_remain_delta',
                  "#{resource.name} requests " +
                  "#{remaining < remainingWork ? 'less' : 'more'} " +
                  "remaining effort for task #{@task.fullId}\n" +
                  "#{remaining}d instead of #{remainingWork}d")
        end
      else
        if @expectedEnd != @task['end', scenarioIdx]
          warning('ts_res_end_delta',
                  "#{resource.name} requests " +
                  "#{@expectedEnd < @task['end', scenarioIdx] ?
                    'earlier' : 'later'} end (#{@expectedEnd}) for task " +
                  "#{@task.fullId}. Planned end is " +
                  "#{@task['end', scenarioIdx]}.")
        end
      end
    end

    def taskId
      @task.is_a?(Task) ? @task.fullId : task
    end

    # The reported work in % (0.0 - 100.0) of the average working time.
    def actualWorkPercent
      (@work.to_f / @timeSheet.totalGrossWorkingSlots) * 100.0
    end

    # The planned work in % (0.0 - 100.0) of the average working time.
    def planWorkPercent
      resource = @timeSheet.resource
      project = resource.project
      scenarioIdx = @timeSheet.scenarioIdx
      startIdx = project.dateToIdx(@timeSheet.interval.start)
      endIdx = project.dateToIdx(@timeSheet.interval.end)
      (@timeSheet.resource.getAllocatedSlots(scenarioIdx, startIdx, endIdx,
                                             @task).to_f /
       @timeSheet.totalGrossWorkingSlots) * 100.0
    end

    # The reporting remaining effort in days.
    def actualRemaining
      project = @timeSheet.resource.project
      project.convertToDailyLoad(@remaining * project['scheduleGranularity'])
    end

    # The remaining effort according to the plan.
    def planRemaining
      resource = @timeSheet.resource
      project = resource.project
      scenarioIdx = @timeSheet.scenarioIdx
      startIdx = project.dateToIdx(project['now'])
      endIdx = project.dateToIdx(@task['end', scenarioIdx])
      @task.getEffectiveWork(scenarioIdx, startIdx, endIdx, resource)
    end

    # The reported expected end of the task.
    def actualEnd
      @expectedEnd
    end

    # The planned end of the task.
    def planEnd
      @task['end', @timeSheet.scenarioIdx]
    end

    private

  end

  # The TimeSheet class stores the work related bits of a time sheet. For each
  # task it holds a TimeSheetRecord object. A time sheet is always bound to an
  # existing Resource.
  class TimeSheet

    attr_accessor :sourceFileInfo
    attr_reader :resource, :interval, :scenarioIdx

    def initialize(resource, interval, scenarioIdx)
      raise "Illegal resource" unless resource.is_a?(Resource)
      @resource = resource
      raise "Interval undefined" if interval.nil?
      @interval = interval
      raise "Sceneario index undefined" if scenarioIdx.nil?
      @scenarioIdx = scenarioIdx
      @sourceFileInfo = nil
      # This flag is set to true if at least one record was reported as
      # percentage.
      @percentageUsed = false
      # The TimeSheetRecord list.
      @records = []
      @messageHandler = MessageHandlerInstance.instance
    end

    # Add a new TimeSheetRecord to the list.
    def<<(record)
      @records.each do |r|
        if r.task == record.task
          error('ts_duplicate_task',
                "Duplicate records for task #{r.taskId}")
        end
      end
      @records << record
    end

    # Perform all kinds of consitency checks.
    def check
      totalSlots = 0
      @records.each do |record|
        record.check
        totalSlots += record.work
      end

      unless (scenarioIdx = @resource.project['trackingScenarioIdx'])
        error('ts_no_tracking_scenario',
              'No trackingscenario has been defined.')
      end

      if @resource['efficiency', scenarioIdx] > 0.0
        targetSlots = totalNetWorkingSlots
        # This is the acceptable rounding error when checking the total
        # reported work.
        delta = 1
        if totalSlots < (targetSlots - delta)
          error('ts_work_too_low',
                "The total work to be reported for this time sheet " +
                "is #{workWithUnit(targetSlots)} but only " +
                "#{workWithUnit(totalSlots)} were reported.")
        end
        if totalSlots > (targetSlots + delta)
          error('ts_work_too_high',
                "The total work to be reported for this time sheet " +
                "is #{workWithUnit(targetSlots)} but " +
                "#{workWithUnit(totalSlots)} were reported.")
        end
      else
        if totalSlots > 0
          error('ts_work_not_null',
                "The reported work for non-working resources must be 0.")
        end
      end
    end

    def warnOnDelta
      project = @resource.project
      startIdx = project.dateToIdx(@interval.start)
      endIdx = project.dateToIdx(@interval.end)

      @records.each do |record|
        record.warnOnDelta(startIdx, endIdx)
      end
    end

    # Compute the total number of potential working time slots during the
    # report period. This value is not resource specific.
    def totalGrossWorkingSlots
      project = @resource.project
      # Calculate the number of weeks in the report
      weeksToReport = (@interval.end - @interval.start).to_f /
        (60 * 60 * 24 * 7)

      daysToSlots((project.weeklyWorkingDays * weeksToReport).to_i)
    end

    # Compute the total number of actual working time slots of the
    # Resource. This is the sum of allocated, free time slots.
    def totalNetWorkingSlots
      project = @resource.project
      startIdx = project.dateToIdx(@interval.start)
      endIdx = project.dateToIdx(@interval.end)
      @resource.getAllocatedSlots(@scenarioIdx, startIdx, endIdx, nil) +
        @resource.getFreeSlots(@scenarioIdx, startIdx, endIdx)
    end

    # Converts allocation percentage into time slots.
    def percentToSlots(value)
      @percentageUsed = true
      (totalGrossWorkingSlots * value).to_i
    end

    # Computes how many percent the _slots_ are of the total working slots in
    # the report time frame.
    def slotsToPercent(slots)
      slots.to_f / totalGrossWorkingSlots
    end

    def slotsToDays(slots)
      slots * @resource.project['scheduleGranularity'] /
        (60 * 60 * @resource.project.dailyWorkingHours)
    end

    def daysToSlots(days)
      ((days * 60 * 60 * @resource.project.dailyWorkingHours) /
       @resource.project['scheduleGranularity']).to_i
    end

    def error(id, text, sourceFileInfo = nil)
      @messageHandler.error(id, text, sourceFileInfo || @sourceFileInfo,
                            nil, @resource)
    end

    def warning(id, text, sourceFileInfo = nil)
      @messageHandler.warning(id, text, sourceFileInfo, nil, @resource)
    end

    private

    def workWithUnit(slots)
      if @percentageUsed
        "#{(slotsToPercent(slots) * 100.0).to_i}%"
      else
        "#{slotsToDays(slots)} days"
      end
    end

  end

  # A class to hold all time sheets of a project.
  class TimeSheets < Array

    def initialize
      super
    end

    def check
      each { |s| s.check }
    end

    def warnOnDelta
      each { |s| s.warnOnDelta }
    end

  end

end