#This file is part of Tryton. The COPYRIGHT file at the top level of #this repository contains the full copyright notices and license terms. import datetime from collections import deque, defaultdict from heapq import heappop, heappush from trytond.model import ModelView, ModelSQL, fields from trytond.wizard import Wizard, StateTransition from trytond.pyson import Eval from trytond.transaction import Transaction from trytond.pool import Pool def intfloor(x): return int(round(x, 4)) class Work(ModelSQL, ModelView): _name = 'project.work' predecessors = fields.Many2Many('project.predecessor_successor', 'successor', 'predecessor', 'Predecessors', domain=[ ('parent', '=', Eval('parent')), ('id', '!=', Eval('id')), ], depends=['parent', 'id']) successors = fields.Many2Many('project.predecessor_successor', 'predecessor', 'successor', 'Successors', domain=[ ('parent', '=', Eval('parent')), ('id', '!=', Eval('id')), ], depends=['parent', 'id']) leveling_delay = fields.Float("Leveling Delay") back_leveling_delay = fields.Float("Leveling Delay") allocations = fields.One2Many('project.allocation', 'work', 'Allocations', states={ 'invisible': Eval('type') != 'task', }, depends=['type']) duration = fields.Function(fields.Float('Duration'), 'get_function_fields') early_start_time = fields.DateTime("Early Start Time", readonly=True) late_start_time = fields.DateTime("Late Start Time", readonly=True) early_finish_time = fields.DateTime("Early Finish Time", readonly=True) late_finish_time = fields.DateTime("Late Finish Time", readonly=True) actual_start_time = fields.DateTime("Actual Start Time") actual_finish_time = fields.DateTime("Actual Finish Time") constraint_start_time = fields.DateTime("Constraint Start Time") constraint_finish_time = fields.DateTime("Constraint Finish Time") early_start_date = fields.Function(fields.Date('Early Start'), 'get_function_fields') late_start_date = fields.Function(fields.Date('Late Start'), 'get_function_fields') early_finish_date = fields.Function(fields.Date('Early Finish'), 'get_function_fields') late_finish_date = fields.Function(fields.Date('Late Finish'), 'get_function_fields') actual_start_date = fields.Function(fields.Date('Actual Start'), 'get_function_fields', setter='set_function_fields') actual_finish_date = fields.Function(fields.Date('Actual Finish'), 'get_function_fields', setter='set_function_fields') constraint_start_date = fields.Function(fields.Date('Constraint Start', depends=['type']), 'get_function_fields', setter='set_function_fields') constraint_finish_date = fields.Function(fields.Date('Constraint Finish', depends=['type']), 'get_function_fields', setter='set_function_fields') requests = fields.Function(fields.One2Many('res.request', None, 'Requests'), 'get_function_fields', setter='set_function_fields') def __init__(self): super(Work, self).__init__() self._constraints += [ ('check_recursion', 'recursive_dependency'), ] self._error_messages.update({ 'recursive_dependency': 'You can not create recursive ' 'dependencies!', }) def check_recursion(self, ids, parent='parent'): return super(Work, self).check_recursion(ids, parent='successors') def get_function_fields(self, ids, names): ''' Function to compute function fields :param ids: the ids of the works :param names: the list of field name to compute :return: a dictionary with all field names as key and a dictionary as value with id as key ''' req_ref_obj = Pool().get('res.request.reference') cursor = Transaction().cursor res = {} if 'requests' in names: requests = dict((i, []) for i in ids) for i in range(0, len(ids), cursor.IN_MAX): sub_ids = ids[i:i + cursor.IN_MAX] req_ref_ids = req_ref_obj.search([ ('reference', 'in', [ 'project.work,%s' % i for i in sub_ids ] ), ]) req_refs = req_ref_obj.browse(req_ref_ids) for req_ref in req_refs: _, work_id = req_ref.reference.split(',') requests[int(work_id)].append(req_ref.request.id) res['requests'] = requests if 'duration' in names: all_ids = self.search([ ('parent', 'child_of', ids), ('active', '=', True)]) + ids all_ids = list(set(all_ids)) works = self.browse(all_ids) durations = {} id2work = {} leafs = set() for work in works: id2work[work.id] = work if not work.children: leafs.add(work.id) total_allocation = 0 if not work.allocations: durations[work.id] = work.effort continue for allocation in work.allocations: total_allocation += allocation.percentage durations[work.id] = work.effort / (total_allocation / 100.0) while leafs: parents = set() for work_id in leafs: work = id2work[work_id] if not work.active: continue if work.parent and work.parent.id in durations: durations[work.parent.id] += durations[work_id] parents.add(work.parent.id) leafs = parents res['duration'] = durations fun_fields = ('early_start_date', 'early_finish_date', 'late_start_date', 'late_finish_date', 'actual_start_date', 'actual_finish_date', 'constraint_start_date', 'constraint_finish_date') db_fields = ('early_start_time', 'early_finish_time', 'late_start_time', 'late_finish_time', 'actual_start_time', 'actual_finish_time', 'constraint_start_time', 'constraint_finish_time') works = None for fun_field, db_field in zip(fun_fields, db_fields): if fun_field in names: values = {} if works is None: works = self.browse(ids) for work in works: values[work.id] = work[db_field] \ and work[db_field].date() or None res[fun_field] = values return res def set_function_fields(self, ids, name, value): request_obj = Pool().get('res.request') req_ref_obj = Pool().get('res.request.reference') if name == 'requests': works = self.browse(ids) currents = dict((req.id, req) for work in works for req in work.requests) if not value: return for v in value: to_unlink = [] to_link = [] operator = v[0] target_ids = len(v) > 1 and v[1] or [] if operator == 'create': to_link.append(request_obj.create(v[1])) elif operator == 'write': request_obj.write(v[1], v[2]) elif operator == 'delete': request_obj.delete(v[1]) elif operator == 'delete_all': target_ids = [] for record_id in ids: ref_ids = req_ref_obj.search([ ('reference', '=', 'project.work,%s' % record_id), ]) refs = req_ref_obj.browse(ref_ids) target_ids.extend(ref.request.id for ref in refs) request_obj.delete(target_ids) elif operator == 'unlink': to_unlink.extend((i for i in target_ids if i in currents)) elif operator == 'add': to_link.extend((i for i in target_ids if i not in currents)) elif operator == 'unlink_all': to_unlink.extend(currents) elif operator == 'set': to_link.extend((i for i in target_ids if i not in currents)) to_unlink.extend((i for i in currents if i not in target_ids)) else: raise Exception('Operation not supported') req_ref_ids = [] for i in to_unlink: request = currents[i] for ref in request.references: if int(ref.reference.split(',')[1]) in ids: req_ref_ids.append(ref.id) req_ref_obj.delete(req_ref_ids) for i in to_link: for record_id in ids: req_ref_obj.create({ 'request': i, 'reference': 'project.work,%s' % record_id, }) return fun_fields = ('actual_start_date', 'actual_finish_date', 'constraint_start_date', 'constraint_finish_date') db_fields = ('actual_start_time', 'actual_finish_time', 'constraint_start_time', 'constraint_finish_time') for fun_field, db_field in zip(fun_fields, db_fields): if fun_field == name: self.write(ids, { db_field: value \ and datetime.datetime.combine(value, datetime.time()) \ or None, }) break def add_minutes(self, company, date, minutes): minutes = int(round(minutes)) minutes = date.minute + minutes hours = minutes // 60 if hours: date = self.add_hours(company, date, hours) minutes = minutes % 60 date = datetime.datetime( date.year, date.month, date.day, date.hour, minutes, date.second) return date def add_hours(self, company, date, hours): while hours: if hours != intfloor(hours): minutes = (hours - intfloor(hours)) * 60 date = self.add_minutes(company, date, minutes) hours = intfloor(hours) hours = date.hour + hours days = hours // company.hours_per_work_day if days: date = self.add_days(company, date, days) hours = hours % company.hours_per_work_day date = datetime.datetime( date.year, date.month, date.day, intfloor(hours), date.minute, date.second) hours = hours - intfloor(hours) return date def add_days(self, company, date, days): day_per_week = company.hours_per_work_week / company.hours_per_work_day while days: if days != intfloor(days): hours = (days - intfloor(days)) * company.hours_per_work_day date = self.add_hours(company, date, hours) days = intfloor(days) days = date.weekday() + days weeks = days // day_per_week days = days % day_per_week if weeks: date = self.add_weeks(company, date, weeks) date += datetime.timedelta(days=-date.weekday() + intfloor(days)) days = days - intfloor(days) return date def add_weeks(self, company, date, weeks): day_per_week = company.hours_per_work_week / company.hours_per_work_day if weeks != intfloor(weeks): days = (weeks - intfloor(weeks)) * day_per_week if days: date = self.add_days(company, date, days) date += datetime.timedelta(days=7 * intfloor(weeks)) return date def compute_dates(self, work_id): active_work = self.browse(work_id) values = {} get_early_finish = lambda work: values.get(work, {}).get( 'early_finish_time', work['early_finish_time']) get_late_start = lambda work: values.get(work, {}).get( 'late_start_time', work['late_start_time']) maxdate = lambda x, y: x and y and max(x, y) or x or y mindate = lambda x, y: x and y and min(x, y) or x or y # propagate constraint_start_time constraint_start = reduce(maxdate, (pred.early_finish_time \ for pred in active_work.predecessors), None) if constraint_start is None and active_work.parent: constraint_start = active_work.parent.early_start_time constraint_start = maxdate(constraint_start, active_work.constraint_start_time) works = deque([(active_work, constraint_start)]) work2children = {} parent = None while works or parent: if parent: work = parent parent = None # Compute early_finish if work.children: early_finish_time = reduce( maxdate, map(get_early_finish, work.children), None) else: early_finish_time = None if values[work]['early_start_time']: early_finish_time = self.add_hours(work.company, values[work]['early_start_time'], work.duration) values[work]['early_finish_time'] = early_finish_time # Propagate constraint_start on successors for w in work.successors: works.append((w, early_finish_time)) if not work.parent: continue # housecleaning work2children if work.parent not in work2children: work2children[work.parent] = set() work2children[work.parent].update(work.successors) if work in work2children[work.parent]: work2children[work.parent].remove(work) # if no sibling continue to walk up the tree if not work2children.get(work.parent): if not work.parent in values: values[work.parent] = {} parent = work.parent continue work, constraint_start = works.popleft() # take constraint define on the work into account constraint_start = maxdate(constraint_start, work.constraint_start_time) if constraint_start: early_start = self.add_hours(work.company, constraint_start, work.leveling_delay) else: early_start = None # update values if not work in values: values[work] = {} values[work]['early_start_time'] = early_start # Loop on children if they exist if work.children and work not in work2children: work2children[work] = set(work.children) # Propagate constraint_start on children for w in work.children: if w.predecessors: continue works.append((w, early_start)) else: parent = work # propagate constraint_finish_time constraint_finish = reduce(mindate, (succ.late_start_time \ for succ in active_work.successors), None) if constraint_finish is None and active_work.parent: constraint_finish = active_work.parent.late_finish_time constraint_finish = mindate(constraint_finish, active_work.constraint_finish_time) works = deque([(active_work, constraint_finish)]) work2children = {} parent = None while works or parent: if parent: work = parent parent = None # Compute late_start if work.children: reduce(mindate, map(get_late_start, work.children), None) else: late_start_time = None if values[work]['late_finish_time']: late_start_time = self.add_hours(work.company, values[work]['late_finish_time'], -work.duration) values[work]['late_start_time'] = late_start_time # Propagate constraint_finish on predecessors for w in work.predecessors: works.append((w, late_start_time)) if not work.parent: continue # housecleaning work2children if work.parent not in work2children: work2children[work.parent] = set() work2children[work.parent].update(work.predecessors) if work in work2children[work.parent]: work2children[work.parent].remove(work) # if no sibling continue to walk up the tree if not work2children.get(work.parent): if not work.parent in values: values[work.parent] = {} parent = work.parent continue work, constraint_finish = works.popleft() # take constraint define on the work into account constraint_finish = mindate(constraint_finish, work.constraint_finish_time) if constraint_finish: late_finish = self.add_hours(work.company, constraint_finish, -work.back_leveling_delay) else: late_finish = None # update values if not work in values: values[work] = {} values[work]['late_finish_time'] = late_finish # Loop on children if they exist if work.children and work not in work2children: work2children[work] = set(work.children) # Propagate constraint_start on children for w in work.children: if w.successors: continue works.append((w, late_finish)) else: parent = work # write values write_fields = ('early_start_time', 'early_finish_time', 'late_start_time', 'late_finish_time') for work, val in values.iteritems(): write_cond = False for field in write_fields: if field in val and work[field] != val[field]: write_cond = True break if write_cond: self.write(work.id, val) def reset_leveling(self, work_id): get_key = lambda w: (set(p.id for p in w.predecessors), set(s.id for s in w.successors)) work = self.browse(work_id) parent_id = work.parent and work.parent.id or None sibling_ids = self.search([ ('parent', '=', parent_id) ]) siblings = self.browse(sibling_ids) to_clean = [] ref_key = get_key(work) for sibling in siblings: if sibling.leveling_delay == sibling.back_leveling_delay == 0: continue if get_key(sibling) == ref_key: to_clean.append(sibling.id) if to_clean: self.write(to_clean, { 'leveling_delay': 0, 'back_leveling_delay': 0, }) def create_leveling(self, work_id): # define some helper functions get_key = lambda w: (set(p.id for p in w.predecessors), set(s.id for s in w.successors)) over_alloc = lambda current_alloc, work: ( reduce(lambda res, alloc: (res or (current_alloc[alloc.employee.id] + alloc.percentage) > 100), work.allocations, False)) def sum_allocs(current_alloc, work): res = defaultdict(float) for alloc in work.allocations: empl = alloc.employee.id res[empl] = current_alloc[empl] + alloc.percentage return res def compute_delays(siblings): # time_line is a list [[end_delay, allocations], ...], this # mean that allocations is valid between the preceding end_delay # (or 0 if it doesn't exist) and the current end_delay. timeline = [] for sibling in siblings: delay = 0 ignored = [] overloaded = [] item = None while timeline: # item is [end_delay, allocations] item = heappop(timeline) if over_alloc(item[1], sibling): ignored.extend(overloaded) ignored.append(item) delay = item[0] continue elif item[1] >= delay + sibling.duration: overloaded.append(item) else: #Succes! break heappush(timeline, [delay + sibling.duration, sum_allocs(defaultdict(float), sibling), sibling.id]) for i in ignored: heappush(timeline, i) for i in overloaded: i[1] = sum_allocs(i[1], sibling) heappush(timeline, i) yield sibling, delay work = self.browse(work_id) parent_id = work.parent and work.parent.id or None sibling_ids = self.search([ ('parent', '=', parent_id) ]) refkey = get_key(work) siblings = [s for s in self.browse(sibling_ids) if get_key(s) == refkey] for sibling, delay in compute_delays(siblings): self.write(sibling.id, { 'leveling_delay': delay, }) siblings.reverse() for sibling, delay in compute_delays(siblings): self.write(sibling.id, { 'back_leveling_delay': delay, }) if parent_id: self.compute_dates(parent_id) def write(self, ids, values): res = super(Work, self).write(ids, values) if isinstance(ids, (int, long)): ids = [ids] if 'effort' in values: for work_id in ids: self.reset_leveling(work_id) fields = ('constraint_start_time', 'constraint_finish_time', 'effort') if reduce(lambda x, y: x or y in values, fields, False): for work_id in ids: self.compute_dates(work_id) return res def create(self, values): work_id = super(Work, self).create(values) self.reset_leveling(work_id) self.compute_dates(work_id) return work_id def delete(self, ids): if isinstance(ids, (int, long)): ids = [ids] works = self.browse(ids) to_update = set() for work in works: if work.parent and work.parent.id not in ids: to_update.add(work.parent.id) to_update.update(c.id for c in work.parent.children \ if c.id not in ids) res = super(Work, self).delete(ids) for work_id in to_update: self.reset_leveling(work_id) self.compute_dates(work_id) return res Work() class PredecessorSuccessor(ModelSQL): 'Predecessor - Successor' _name = 'project.predecessor_successor' _description = __doc__ predecessor = fields.Many2One('project.work', 'Predecessor', ondelete='CASCADE', required=True, select=True) successor = fields.Many2One('project.work', 'Successor', ondelete='CASCADE', required=True, select=True) def write(self, ids, values): work_obj = Pool().get('project.work') res = super(PredecessorSuccessor, self).write(ids, values) for work_id in values.itervalues(): work_obj.reset_leveling(work_id) for work_id in values.itervalues(): work_obj.compute_dates(work_id) return res def delete(self, ids): work_obj = Pool().get('project.work') if isinstance(ids, (int, long)): ids = [ids] work_ids = set() parent_ids = set() pred_succs = self.browse(ids) for pred_succ in pred_succs: work_ids.update((pred_succ.predecessor.id, pred_succ.successor.id)) if pred_succ.predecessor.parent: parent_ids.add(pred_succ.predecessor.parent.id) res = super(PredecessorSuccessor, self).delete(ids) for work_id in work_ids: work_obj.reset_leveling(work_id) for parent_id in parent_ids: work_obj.compute_dates(parent_id) return res def create(self, values): work_obj = Pool().get('project.work') ps_id = super(PredecessorSuccessor, self).create(values) pred_succ = self.browse(ps_id) work_obj.reset_leveling(pred_succ.predecessor.id) work_obj.reset_leveling(pred_succ.successor.id) if pred_succ.predecessor.parent: work_obj.compute_dates(pred_succ.predecessor.parent.id) return id PredecessorSuccessor() class Leveling(Wizard): 'Tasks Leveling' _name = 'project_plan.work.leveling' start_state = 'leveling' leveling = StateTransition() def transition_leveling(self, session): work_obj = Pool().get('project.work') work_obj.create_leveling(Transaction().context['active_id']) return 'end' Leveling()