diff --git a/addons/account/report/account_analytic_entries_report_view.xml b/addons/account/report/account_analytic_entries_report_view.xml index 8a421e3cb36..047d563cc58 100644 --- a/addons/account/report/account_analytic_entries_report_view.xml +++ b/addons/account/report/account_analytic_entries_report_view.xml @@ -1,32 +1,6 @@ - - analytic.entries.report.tree - analytic.entries.report - - - - - - - - - - - - - - - - - - - - - - - analytic.entries.report.search analytic.entries.report @@ -58,8 +32,8 @@ account.analytic.entries.graph analytic.entries.report - - + + @@ -69,7 +43,7 @@ Analytic Entries Analysis analytic.entries.report form - tree,graph + graph {'search_default_year':1,'search_default_month':1, 'group_by_no_leaf':1, 'search_default_Account':1, 'search_default_Month':1, 'group_by':[]} From this view, have an analysis of your different analytic entries following the analytic account you defined matching your business need. Use the tool search to analyse information about analytic entries generated in the system. diff --git a/addons/account/report/account_entries_report_view.xml b/addons/account/report/account_entries_report_view.xml index 66959051ea4..2d1af681062 100644 --- a/addons/account/report/account_entries_report_view.xml +++ b/addons/account/report/account_entries_report_view.xml @@ -38,8 +38,8 @@ account.entries.report.graph account.entries.report - - + + @@ -103,9 +103,9 @@ Entries Analysis account.entries.report form - tree,graph + graph - + {'group_by':[], 'search_default_usertype':1, 'search_default_thisyear':1, 'group_by_no_leaf':1} From this view, have an analysis of your different financial accounts. The document shows your debit and credit taking in consideration some criteria you can choose by using the search tool. diff --git a/addons/account/report/account_invoice_report_view.xml b/addons/account/report/account_invoice_report_view.xml index 47ce75b4d61..67d568801b7 100644 --- a/addons/account/report/account_invoice_report_view.xml +++ b/addons/account/report/account_invoice_report_view.xml @@ -1,47 +1,14 @@ - - account.invoice.report.tree - account.invoice.report - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - account.invoice.report.graph account.invoice.report - - - + + + + @@ -72,7 +39,7 @@ - + @@ -92,8 +59,8 @@ Invoices Analysis account.invoice.report form - tree,graph - {'search_default_period':1,'search_default_current':1, 'search_default_year': 1, 'search_default_category_product':1, 'search_default_customer':1, 'group_by':[], 'group_by_no_leaf':1,} + graph + {'search_default_current':1, 'search_default_year': 1, 'search_default_customer':1, 'group_by':[], 'group_by_no_leaf':1,} From this report, you can have an overview of the amount invoiced to your customer. The tool search can also be used to personalise your Invoices reports and so, match this analysis to your needs. diff --git a/addons/account/report/account_treasury_report_view.xml b/addons/account/report/account_treasury_report_view.xml index be6ae0dfdc9..fe898cd81ce 100644 --- a/addons/account/report/account_treasury_report_view.xml +++ b/addons/account/report/account_treasury_report_view.xml @@ -22,9 +22,9 @@ account.treasury.report.graph account.treasury.report - - - + + + @@ -43,9 +43,9 @@ Treasury Analysis account.treasury.report form - tree,graph + graph - + {'group_by':[], 'group_by_no_leaf':0} From this view, have an analysis of your treasury. It sums the balance of every accounting entries made on liquidity accounts per period. diff --git a/addons/account/res_config.py b/addons/account/res_config.py index bf28e900378..8980a31927e 100644 --- a/addons/account/res_config.py +++ b/addons/account/res_config.py @@ -105,6 +105,9 @@ class account_config_settings(osv.osv_memory): 'module_account_followup': fields.boolean('Manage customer payment follow-ups', help='This allows to automate letters for unpaid invoices, with multi-level recalls.\n' '-This installs the module account_followup.'), + 'module_product_email_template': fields.boolean('Send products tools and information at the invoice confirmation', + help='With this module, link your products to a template to send complete information and tools to your customer.\n' + 'For instance when invoicing a training, the training agenda and materials will automatically be send to your customers.'), 'group_proforma_invoices': fields.boolean('Allow pro-forma invoices', implied_group='account.group_proforma_invoices', help="Allows you to put invoices in pro-forma state."), diff --git a/addons/account/res_config_view.xml b/addons/account/res_config_view.xml index ce8b41fc2da..103344daa3a 100644 --- a/addons/account/res_config_view.xml +++ b/addons/account/res_config_view.xml @@ -183,6 +183,10 @@ + diff --git a/addons/product_email_template/models/__init__.py b/addons/product_email_template/models/__init__.py new file mode 100644 index 00000000000..2edb825574d --- /dev/null +++ b/addons/product_email_template/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +import product +import invoice diff --git a/addons/product_email_template/models/invoice.py b/addons/product_email_template/models/invoice.py new file mode 100644 index 00000000000..d9e82e42012 --- /dev/null +++ b/addons/product_email_template/models/invoice.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +from openerp.osv import osv + + +class account_invoice(osv.Model): + _inherit = 'account.invoice' + + def invoice_validate_send_email(self, cr, uid, ids, context=None): + Composer = self.pool['mail.compose.message'] + for invoice in self.browse(cr, uid, ids, context=context): + # send template only on customer invoice + if invoice.type != 'out_invoice': + continue + # subscribe the partner to the invoice + if invoice.partner_id.id not in invoice.message_follower_ids: + self.message_subscribe(cr, uid, [invoice.id], [invoice.partner_id.id], context=context) + for line in invoice.invoice_line: + if line.product_id.email_template_id: + # CLEANME: should define and use a clean API: message_post with a template + composer_id = Composer.create(cr, uid, { + 'model': 'account.invoice', + 'res_id': invoice.id, + 'template_id': line.product_id.email_template_id.id, + 'composition_mode': 'comment', + }, context=context) + template_values = Composer.onchange_template_id( + cr, uid, composer_id, line.product_id.email_template_id.id, 'comment', 'account.invoice', invoice.id + )['value'] + template_values['attachment_ids'] = [(4, id) for id in template_values.get('attachment_ids', '[]')] + Composer.write(cr, uid, [composer_id], template_values, context=context) + Composer.send_mail(cr, uid, [composer_id], context=context) + return True + + def invoice_validate(self, cr, uid, ids, context=None): + res = super(account_invoice, self).invoice_validate(cr, uid, ids, context=context) + self.invoice_validate_send_email(cr, uid, ids, context=context) + return res diff --git a/addons/product_email_template/models/product.py b/addons/product_email_template/models/product.py new file mode 100644 index 00000000000..cafdef7ab9d --- /dev/null +++ b/addons/product_email_template/models/product.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from openerp.osv import fields, osv + + +class product_template(osv.Model): + """ Product Template inheritance to add an optional email.template to a + product.template. When validating an invoice, an email will be send to the + customer based on this template. The customer will receive an email for each + product linked to an email template. """ + _inherit = "product.template" + + _columns = { + 'email_template_id': fields.many2one( + 'email.template', 'Product Email Template', + help='When validating an invoice, an email will be send to the customer' + 'based on this template. The customer will receive an email for each' + 'product linked to an email template.'), + } diff --git a/addons/product_email_template/views/email_template_view.xml b/addons/product_email_template/views/email_template_view.xml new file mode 100644 index 00000000000..5b4600b9719 --- /dev/null +++ b/addons/product_email_template/views/email_template_view.xml @@ -0,0 +1,24 @@ + + + + + email.template.form.simplified + email.template + 100 + +
+ + + + +

Body

+ + +
+
+
+
+
+
diff --git a/addons/product_email_template/views/product_view.xml b/addons/product_email_template/views/product_view.xml new file mode 100644 index 00000000000..4d036fb8de0 --- /dev/null +++ b/addons/product_email_template/views/product_view.xml @@ -0,0 +1,22 @@ + + + + + product.normal.procurement.locations.inherit + product.product + + + + + + + + + diff --git a/addons/project/project_view.xml b/addons/project/project_view.xml index 38e8fc9fdef..08738227f5a 100644 --- a/addons/project/project_view.xml +++ b/addons/project/project_view.xml @@ -591,9 +591,8 @@ project.task - - - + + diff --git a/addons/project/report/project_cumulative.xml b/addons/project/report/project_cumulative.xml index 9b9490f0b28..8886ae17505 100644 --- a/addons/project/report/project_cumulative.xml +++ b/addons/project/report/project_cumulative.xml @@ -2,31 +2,14 @@ - - project.task.history.cumulative.tree - project.task.history.cumulative - - - - - - - - - - - - - project.task.history.cumulative.graph project.task.history.cumulative - - - - - + + + + @@ -56,7 +39,7 @@ Cumulative Flow project.task.history.cumulative form - graph,tree + graph {"search_default_open":1, "search_default_this_month": 1} diff --git a/addons/project/report/project_report_view.xml b/addons/project/report/project_report_view.xml index d5638e332a4..6fb4e24e7ad 100644 --- a/addons/project/report/project_report_view.xml +++ b/addons/project/report/project_report_view.xml @@ -6,47 +6,13 @@ groups="project.group_project_manager" parent="base.menu_reporting" sequence="25"/> - - report.project.task.user.tree - report.project.task.user - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - report.project.task.user.graph report.project.task.user - - - - + + + @@ -91,7 +57,7 @@ Tasks Analysis report.project.task.user form - tree,graph + graph {'search_default_year':1,'search_default_project':1,'group_by_no_leaf':1,'group_by':[]} This report allows you to analyse the performance of your projects and users. You can analyse the quantities of tasks, the hours spent compared to the planned hours, the average number of days to open or close a task, etc. diff --git a/addons/project_issue/report/project_issue_report_view.xml b/addons/project_issue/report/project_issue_report_view.xml index f9ca7168841..ad6d6688d1e 100644 --- a/addons/project_issue/report/project_issue_report_view.xml +++ b/addons/project_issue/report/project_issue_report_view.xml @@ -1,44 +1,14 @@ - - - project.issue.report.tree - project.issue.report - - - - - - - - - - - - - - - - - - - - - - - - - - project.issue.report.graph project.issue.report - - - - + + + + @@ -81,20 +51,13 @@ Issues Analysis project.issue.report form - graph,tree + graph {'search_default_year':1,'search_default_This Month':1,'search_default_project':1,'group_by_no_leaf':1,'group_by':[]} - + This report on the project issues allows you to analyse the quality of your support or after-sales services. You can track the issues per age. You can analyse the time required to open or close an issue, the number of email to exchange and the time spent on average by issues. - - - - tree - - - graph diff --git a/addons/purchase/report/purchase_report_view.xml b/addons/purchase/report/purchase_report_view.xml index 58270f0ccd1..baaf2f8312c 100644 --- a/addons/purchase/report/purchase_report_view.xml +++ b/addons/purchase/report/purchase_report_view.xml @@ -5,46 +5,13 @@ product.month.graph purchase.report - - - + + + - - purchase.order.tree - purchase.report - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - report.purchase.order.search purchase.report @@ -113,8 +80,8 @@ Purchase Analysis purchase.report form - tree,graph - + graph + {'search_default_year':1,'search_default_month':1,'search_default_group_partner_id':1,'search_default_group_product_id': 1, 'search_default_orders': 1, 'group_by_no_leaf':1,'group_by':[]} Purchase Analysis allows you to easily check and analyse your company purchase history and performance. From this menu you can track your negotiation performance, the delivery performance of your suppliers, etc. @@ -123,7 +90,7 @@ Total Qty and Amount by month purchase.report form - graph,tree + graph @@ -131,7 +98,7 @@ Total Orders by User per month purchase.report form - graph,tree + graph @@ -144,7 +111,7 @@ Receptions Analysis report.stock.move form - tree,graph + graph {'full':'1','contact_display': 'partner','search_default_done':1, 'search_default_month':1, 'search_default_group_type':1, 'group_by': [], 'group_by_no_leaf':1,'search_default_year':1,} Reception Analysis allows you to easily check and analyse your company order receptions and the performance of your supplier's deliveries. diff --git a/addons/resource/resource.py b/addons/resource/resource.py index 212a7dd6828..cbaf15147d1 100644 --- a/addons/resource/resource.py +++ b/addons/resource/resource.py @@ -2,7 +2,7 @@ ############################################################################## # # OpenERP, Open Source Management Solution -# Copyright (C) 2004-2009 Tiny SPRL (). +# Copyright (C) 2004-TODAY OpenERP SA (http://www.openerp.com) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -19,304 +19,613 @@ # ############################################################################## -import pytz -from datetime import datetime, timedelta +import datetime from dateutil import rrule -import math +from dateutil.relativedelta import relativedelta +from operator import itemgetter + from faces import * +from openerp import tools from openerp.osv import fields, osv from openerp.tools.float_utils import float_compare from openerp.tools.translate import _ -from itertools import groupby -from operator import itemgetter - class resource_calendar(osv.osv): + """ Calendar model for a resource. It has + + - attendance_ids: list of resource.calendar.attendance that are a working + interval in a given weekday. + - leave_ids: list of leaves linked to this calendar. A leave can be general + or linked to a specific resource, depending on its resource_id. + + All methods in this class use intervals. An interval is a tuple holding + (begin_datetime, end_datetime). A list of intervals is therefore a list of + tuples, holding several intervals of work or leaves. """ _name = "resource.calendar" _description = "Resource Calendar" + _columns = { - 'name' : fields.char("Name", size=64, required=True), - 'company_id' : fields.many2one('res.company', 'Company', required=False), - 'attendance_ids' : fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time'), - 'manager' : fields.many2one('res.users', 'Workgroup Manager'), + 'name': fields.char("Name", size=64, required=True), + 'company_id': fields.many2one('res.company', 'Company', required=False), + 'attendance_ids': fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time'), + 'manager': fields.many2one('res.users', 'Workgroup Manager'), + 'leave_ids': fields.one2many( + 'resource.calendar.leaves', 'calendar_id', 'Leaves', + help='' + ), } _defaults = { 'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.calendar', context=context) } - def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None): - """Calculates the Working Total Hours based on Resource Calendar and - given working day (datetime object). + # -------------------------------------------------- + # Utility methods + # -------------------------------------------------- - @param resource_calendar_id: resource.calendar browse record - @param day: datetime object + def interval_clean(self, intervals): + """ Utility method that sorts and removes overlapping inside datetime + intervals. The intervals are sorted based on increasing starting datetime. + Overlapping intervals are merged into a single one. - @return: returns the working hours (as float) men should work on the given day if is in the attendance_ids of the resource_calendar_id (i.e if that day is a working day), returns 0.0 otherwise - """ - res = 0.0 - for working_day in resource_calendar_id.attendance_ids: - if (int(working_day.dayofweek) + 1) == day.isoweekday(): - res += working_day.hour_to - working_day.hour_from - return res + :param list intervals: list of intervals; each interval is a tuple + (datetime_from, datetime_to) + :return list cleaned: list of sorted intervals without overlap """ + intervals = sorted(intervals, key=itemgetter(0)) # sort on first datetime + cleaned = [] + working_interval = None + while intervals: + current_interval = intervals.pop(0) + if not working_interval: # init + working_interval = [current_interval[0], current_interval[1]] + elif working_interval[1] < current_interval[0]: # interval is disjoint + cleaned.append(tuple(working_interval)) + working_interval = [current_interval[0], current_interval[1]] + elif working_interval[1] < current_interval[1]: # union of greater intervals + working_interval[1] = current_interval[1] + if working_interval: # handle void lists + cleaned.append(tuple(working_interval)) + return cleaned - def _get_leaves(self, cr, uid, id, resource): - """Private Method to Calculate resource Leaves days + def interval_remove_leaves(self, interval, leave_intervals): + """ Utility method that remove leave intervals from a base interval: - @param id: resource calendar id - @param resource: resource id for which leaves will ew calculated + - clean the leave intervals, to have an ordered list of not-overlapping + intervals + - initiate the current interval to be the base interval + - for each leave interval: - @return : returns the list of dates, where resource on leave in - resource.calendar.leaves object (e.g.['%Y-%m-%d', '%Y-%m-%d']) - """ - resource_cal_leaves = self.pool.get('resource.calendar.leaves') - dt_leave = [] - resource_leave_ids = resource_cal_leaves.search(cr, uid, [('calendar_id','=',id), '|', ('resource_id','=',False), ('resource_id','=',resource)]) - #res_leaves = resource_cal_leaves.read(cr, uid, resource_leave_ids, ['date_from', 'date_to']) - res_leaves = resource_cal_leaves.browse(cr, uid, resource_leave_ids) + - finishing before the current interval: skip, go to next + - beginning after the current interval: skip and get out of the loop + because we are outside range (leaves are ordered) + - beginning within the current interval: close the current interval + and begin a new current interval that begins at the end of the leave + interval + - ending within the current interval: update the current interval begin + to match the leave interval ending - for leave in res_leaves: - dtf = datetime.strptime(leave.date_from, '%Y-%m-%d %H:%M:%S') - dtt = datetime.strptime(leave.date_to, '%Y-%m-%d %H:%M:%S') - no = dtt - dtf - [dt_leave.append((dtf + timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))] - dt_leave.sort() - - return dt_leave - - def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False): - """ - Calculates the working Schedule from supplied from date to till hours - will be satisfied based or resource calendar id. If resource is also - given then it will consider the resource leave also and than will - calculates resource working schedule - - @param dt_from: datetime object, start of working scheduled - @param hours: float, total number working hours needed scheduled from - start date - @param resource : Optional Resource id, if supplied than resource leaves - will also taken into consideration for calculating working - schedule. - @return : List datetime object of working schedule based on supplies - params - """ - if not id: - td = int(hours)*3 - return [(dt_from - timedelta(hours=td), dt_from)] - dt_leave = self._get_leaves(cr, uid, id, resource) - dt_leave.reverse() - todo = hours - result = [] - maxrecur = 100 - current_hour = dt_from.hour - while float_compare(todo, 0, 4) and maxrecur: - cr.execute("select hour_from,hour_to from resource_calendar_attendance where dayofweek='%s' and calendar_id=%s order by hour_from desc", (dt_from.weekday(),id)) - for (hour_from,hour_to) in cr.fetchall(): - leave_flag = False - if (hour_fromtodo: - hour_from = m-todo - dt_check = dt_from.strftime('%Y-%m-%d') - for leave in dt_leave: - if dt_check == leave: - dt_check = datetime.strptime(dt_check, '%Y-%m-%d') + timedelta(days=1) - leave_flag = True - if leave_flag: - break - else: - d1 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(hour_from)), int((hour_from%1) * 60)) - d2 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(m)), int((m%1) * 60)) - result.append((d1, d2)) - current_hour = hour_from - todo -= (m-hour_from) - dt_from -= timedelta(days=1) - current_hour = 24 - maxrecur -= 1 - result.reverse() - return result - - # def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True): - def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True): - def group(lst, key): - lst.sort(key=itemgetter(key)) - grouped = groupby(lst, itemgetter(key)) - return dict([(k, [v for v in itr]) for k, itr in grouped]) - # END group - - cr.execute("select calendar_id, dayofweek, hour_from, hour_to from resource_calendar_attendance order by hour_from") - hour_res = cr.dictfetchall() - hours_by_cal = group(hour_res, 'calendar_id') - - results = {} - - for d, hours, id in date_and_hours_by_cal: - dt_from = datetime.strptime(d, '%Y-%m-%d %H:%M:%S') - if not id: - td = int(hours)*3 - results[(d, hours, id)] = [(dt_from, dt_from + timedelta(hours=td))] + :param tuple interval: a tuple (beginning datetime, ending datetime) that + is the base interval from which the leave intervals + will be removed + :param list leave_intervals: a list of tuples (beginning datetime, ending datetime) + that are intervals to remove from the base interval + :return list intervals: a list of tuples (begin datetime, end datetime) + that are the remaining valid intervals """ + if not interval: + return interval + if leave_intervals is None: + leave_intervals = [] + intervals = [] + leave_intervals = self.interval_clean(leave_intervals) + current_interval = [interval[0], interval[1]] + for leave in leave_intervals: + if leave[1] <= current_interval[0]: continue + if leave[0] >= current_interval[1]: + break + if current_interval[0] < leave[0] < current_interval[1]: + current_interval[1] = leave[0] + intervals.append((current_interval[0], current_interval[1])) + current_interval = [leave[1], interval[1]] + # if current_interval[0] <= leave[1] <= current_interval[1]: + if current_interval[0] <= leave[1]: + current_interval[0] = leave[1] + if current_interval and current_interval[0] < interval[1]: # remove intervals moved outside base interval due to leaves + intervals.append((current_interval[0], current_interval[1])) + return intervals - dt_leave = self._get_leaves(cr, uid, id, resource) - todo = hours - result = [] - maxrecur = 100 - current_hour = dt_from.hour - while float_compare(todo, 0, 4) and maxrecur: - for (hour_from,hour_to) in [(item['hour_from'], item['hour_to']) for item in hours_by_cal[id] if item['dayofweek'] == str(dt_from.weekday())]: - leave_flag = False - if (hour_to>current_hour) and float_compare(todo, 0, 4): - m = max(hour_from, current_hour) - if (hour_to-m)>todo: - hour_to = m+todo - dt_check = dt_from.strftime('%Y-%m-%d') - for leave in dt_leave: - if dt_check == leave: - dt_check = datetime.strptime(dt_check, '%Y-%m-%d') + timedelta(days=1) - leave_flag = True - if leave_flag: - break - else: - d1 = datetime(dt_from.year, dt_from.month, dt_from.day) + timedelta(hours=int(math.floor(m)), minutes=int((m%1) * 60)) - d2 = datetime(dt_from.year, dt_from.month, dt_from.day) + timedelta(hours=int(math.floor(hour_to)), minutes=int((hour_to%1) * 60)) - result.append((d1, d2)) - current_hour = hour_to - todo -= (hour_to - m) - dt_from += timedelta(days=1) - current_hour = 0 - maxrecur -= 1 - results[(d, hours, id)] = result + def interval_schedule_hours(self, intervals, hour, remove_at_end=True): + """ Schedule hours in intervals. The last matching interval is truncated + to match the specified hours. + + It is possible to truncate the last interval at its beginning or ending. + However this does nothing on the given interval order that should be + submitted accordingly. + + :param list intervals: a list of tuples (beginning datetime, ending datetime) + :param int/float hours: number of hours to schedule. It will be converted + into a timedelta, but should be submitted as an + int or float. + :param boolean remove_at_end: remove extra hours at the end of the last + matching interval. Otherwise, do it at the + beginning. + + :return list results: a list of intervals. If the number of hours to schedule + is greater than the possible scheduling in the intervals, no extra-scheduling + is done, and results == intervals. """ + results = [] + res = datetime.timedelta() + limit = datetime.timedelta(hours=hour) + for interval in intervals: + res += interval[1] - interval[0] + if res > limit and remove_at_end: + interval = (interval[0], interval[1] + relativedelta(seconds=(limit-res).total_seconds())) + elif res > limit: + interval = (interval[0] + relativedelta(seconds=(res-limit).total_seconds()), interval[1]) + results.append(interval) + if res > limit: + break return results - def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True): - """Calculates Resource Working Internal Timing Based on Resource Calendar. + # -------------------------------------------------- + # Date and hours computation + # -------------------------------------------------- - @param dt_from: start resource schedule calculation. - @param hours : total number of working hours to be scheduled. - @param resource: optional resource id, If supplied it will take care of - resource leave while scheduling. - @param byday: boolean flag bit enforce day wise scheduling + def get_attendances_for_weekdays(self, cr, uid, id, weekdays, context=None): + """ Given a list of weekdays, return matching resource.calendar.attendance""" + calendar = self.browse(cr, uid, id, context=None) + return [att for att in calendar.attendance_ids if int(att.dayofweek) in weekdays] - @return : list of scheduled working timing based on resource calendar. + def get_weekdays(self, cr, uid, id, default_weekdays=None, context=None): + """ Return the list of weekdays that contain at least one working interval. + If no id is given (no calendar), return default weekdays. """ + if id is None: + return default_weekdays if default_weekdays is not None else [0, 1, 2, 3, 4] + calendar = self.browse(cr, uid, id, context=None) + weekdays = set() + for attendance in calendar.attendance_ids: + weekdays.add(int(attendance.dayofweek)) + return list(weekdays) + + def get_next_day(self, cr, uid, id, day_date, context=None): + """ Get following date of day_date, based on resource.calendar. If no + calendar is provided, just return the next day. + + :param int id: id of a resource.calendar. If not given, simply add one day + to the submitted date. + :param date day_date: current day as a date + + :return date: next day of calendar, or just next day """ + if not id: + return day_date + relativedelta(days=1) + weekdays = self.get_weekdays(cr, uid, id, context) + + base_index = -1 + for weekday in weekdays: + if weekday > day_date.weekday(): + break + base_index += 1 + + new_index = (base_index + 1) % len(weekdays) + days = (weekdays[new_index] - day_date.weekday()) + if days < 0: + days = 7 + days + + return day_date + relativedelta(days=days) + + def get_previous_day(self, cr, uid, id, day_date, context=None): + """ Get previous date of day_date, based on resource.calendar. If no + calendar is provided, just return the previous day. + + :param int id: id of a resource.calendar. If not given, simply remove + one day from the submitted date. + :param date day_date: current day as a date + + :return date: previous day of calendar, or just previous day """ + if not id: + return day_date + relativedelta(days=-1) + weekdays = self.get_weekdays(cr, uid, id, context) + weekdays.reverse() + + base_index = -1 + for weekday in weekdays: + if weekday < day_date.weekday(): + break + base_index += 1 + + new_index = (base_index + 1) % len(weekdays) + days = (weekdays[new_index] - day_date.weekday()) + if days > 0: + days = days - 7 + + return day_date + relativedelta(days=days) + + def get_leave_intervals(self, cr, uid, id, resource_id=None, + start_datetime=None, end_datetime=None, + context=None): + """Get the leaves of the calendar. Leaves can be filtered on the resource, + the start datetime or the end datetime. + + :param int resource_id: the id of the resource to take into account when + computing the leaves. If not set, only general + leaves are computed. If set, generic and + specific leaves are computed. + :param datetime start_datetime: if provided, do not take into account leaves + ending before this date. + :param datetime end_datetime: if provided, do not take into account leaves + beginning after this date. + + :return list leaves: list of tuples (start_datetime, end_datetime) of + leave intervals """ - res = self.interval_get_multi(cr, uid, [(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)], resource, byday)[(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)] + resource_calendar = self.browse(cr, uid, id, context=context) + leaves = [] + for leave in resource_calendar.leave_ids: + if leave.resource_id and not resource_id == leave.resource_id.id: + continue + date_from = datetime.datetime.strptime(leave.date_from, tools.DEFAULT_SERVER_DATETIME_FORMAT) + if end_datetime and date_from > end_datetime: + continue + date_to = datetime.datetime.strptime(leave.date_to, tools.DEFAULT_SERVER_DATETIME_FORMAT) + if start_datetime and date_to < start_datetime: + continue + leaves.append((date_from, date_to)) + return leaves + + def get_working_intervals_of_day(self, cr, uid, id, start_dt=None, end_dt=None, + leaves=None, compute_leaves=False, resource_id=None, + default_interval=None, context=None): + """ Get the working intervals of the day based on calendar. This method + handle leaves that come directly from the leaves parameter or can be computed. + + :param int id: resource.calendar id; take the first one if is a list + :param datetime start_dt: datetime object that is the beginning hours + for the working intervals computation; any + working interval beginning before start_dt + will be truncated. If not set, set to end_dt + or today() if no end_dt at 00.00.00. + :param datetime end_dt: datetime object that is the ending hour + for the working intervals computation; any + working interval ending after end_dt + will be truncated. If not set, set to start_dt() + at 23.59.59. + :param list leaves: a list of tuples(start_datetime, end_datetime) that + represent leaves. + :param boolean compute_leaves: if set and if leaves is None, compute the + leaves based on calendar and resource. + If leaves is None and compute_leaves false + no leaves are taken into account. + :param int resource_id: the id of the resource to take into account when + computing the leaves. If not set, only general + leaves are computed. If set, generic and + specific leaves are computed. + :param tuple default_interval: if no id, try to return a default working + day using default_interval[0] as beginning + hour, and default_interval[1] as ending hour. + Example: default_interval = (8, 16). + Otherwise, a void list of working intervals + is returned when id is None. + + :return list intervals: a list of tuples (start_datetime, end_datetime) + of work intervals """ + if isinstance(id, (list, tuple)): + id = id[0] + + # Computes start_dt, end_dt (with default values if not set) + off-interval work limits + work_limits = [] + if start_dt is None and end_dt is not None: + start_dt = end_dt.replace(hour=0, minute=0, second=0) + elif start_dt is None: + start_dt = datetime.datetime.now().replace(hour=0, minute=0, second=0) + else: + work_limits.append((start_dt.replace(hour=0, minute=0, second=0), start_dt)) + if end_dt is None: + end_dt = start_dt.replace(hour=23, minute=59, second=59) + else: + work_limits.append((end_dt, end_dt.replace(hour=23, minute=59, second=59))) + assert start_dt.date() == end_dt.date(), 'get_working_intervals_of_day is restricted to one day' + + intervals = [] + work_dt = start_dt.replace(hour=0, minute=0, second=0) + + # no calendar: try to use the default_interval, then return directly + if id is None: + if default_interval: + intervals.append((start_dt.replace(hour=default_interval[0]), start_dt.replace(hour=default_interval[1]))) + return intervals + + working_intervals = [] + for calendar_working_day in self.get_attendances_for_weekdays(cr, uid, id, [start_dt.weekday()], context): + working_interval = ( + work_dt.replace(hour=int(calendar_working_day.hour_from)), + work_dt.replace(hour=int(calendar_working_day.hour_to)) + ) + working_intervals += self.interval_remove_leaves(working_interval, work_limits) + + # find leave intervals + if leaves is None and compute_leaves: + leaves = self.get_leave_intervals(cr, uid, id, resource_id=resource_id, context=None) + + # filter according to leaves + for interval in working_intervals: + work_intervals = self.interval_remove_leaves(interval, leaves) + intervals += work_intervals + + return intervals + + def get_working_hours_of_date(self, cr, uid, id, start_dt=None, end_dt=None, + leaves=None, compute_leaves=False, resource_id=None, + default_interval=None, context=None): + """ Get the working hours of the day based on calendar. This method uses + get_working_intervals_of_day to have the work intervals of the day. It + then calculates the number of hours contained in those intervals. """ + res = datetime.timedelta() + intervals = self.get_working_intervals_of_day( + cr, uid, id, + start_dt, end_dt, leaves, + compute_leaves, resource_id, + default_interval, context) + for interval in intervals: + res += interval[1] - interval[0] + return (res.total_seconds() / 3600.0) + + def get_working_hours(self, cr, uid, id, start_dt, end_dt, compute_leaves=False, + resource_id=None, default_interval=None, context=None): + hours = 0.0 + for day in rrule.rrule(rrule.DAILY, dtstart=start_dt, + until=end_dt + datetime.timedelta(days=1), + byweekday=self.get_weekdays(cr, uid, id, context=context)): + hours += self.get_working_hours_of_date( + cr, uid, id, start_dt=day, + compute_leaves=compute_leaves, resource_id=resource_id, + default_interval=default_interval, + context=context) + return hours + + # -------------------------------------------------- + # Hours scheduling + # -------------------------------------------------- + + def _schedule_hours(self, cr, uid, id, hours, day_dt=None, + compute_leaves=False, resource_id=None, + default_interval=None, context=None): + """ Schedule hours of work, using a calendar and an optional resource to + compute working and leave days. This method can be used backwards, i.e. + scheduling days before a deadline. + + :param int hours: number of hours to schedule. Use a negative number to + compute a backwards scheduling. + :param datetime day_dt: reference date to compute working days. If days is + > 0 date is the starting date. If days is < 0 + date is the ending date. + :param boolean compute_leaves: if set, compute the leaves based on calendar + and resource. Otherwise no leaves are taken + into account. + :param int resource_id: the id of the resource to take into account when + computing the leaves. If not set, only general + leaves are computed. If set, generic and + specific leaves are computed. + :param tuple default_interval: if no id, try to return a default working + day using default_interval[0] as beginning + hour, and default_interval[1] as ending hour. + Example: default_interval = (8, 16). + Otherwise, a void list of working intervals + is returned when id is None. + + :return tuple (datetime, intervals): datetime is the beginning/ending date + of the schedulign; intervals are the + working intervals of the scheduling. + + Note: Why not using rrule.rrule ? Because rrule does not seem to allow + getting back in time. + """ + if day_dt is None: + day_dt = datetime.datetime.now() + backwards = (hours < 0) + hours = abs(hours) + intervals = [] + remaining_hours = hours * 1.0 + iterations = 0 + current_datetime = day_dt + + call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id, default_interval=default_interval, context=context) + + while float_compare(remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000: + if backwards: + call_args['end_dt'] = current_datetime + else: + call_args['start_dt'] = current_datetime + + working_intervals = self.get_working_intervals_of_day(cr, uid, id, **call_args) + + if id is None and not working_interval: # no calendar -> consider working 8 hours + remaining_hours -= 8.0 + elif working_intervals: + if backwards: + working_intervals.reverse() + new_working_intervals = self.interval_schedule_hours(working_intervals, remaining_hours, not backwards) + if backwards: + new_working_intervals.reverse() + + res = datetime.timedelta() + for interval in working_intervals: + res += interval[1] - interval[0] + remaining_hours -= (res.total_seconds() / 3600.0) + if backwards: + intervals = new_working_intervals + intervals + else: + intervals = intervals + new_working_intervals + # get next day + if backwards: + current_datetime = datetime.datetime.combine(self.get_previous_day(cr, uid, id, current_datetime, context), datetime.time(23, 59, 59)) + else: + current_datetime = datetime.datetime.combine(self.get_next_day(cr, uid, id, current_datetime, context), datetime.time()) + # avoid infinite loops + iterations += 1 + + return intervals + + def schedule_hours_get_date(self, cr, uid, id, hours, day_dt=None, + compute_leaves=False, resource_id=None, + default_interval=None, context=None): + """ Wrapper on _schedule_hours: return the beginning/ending datetime of + an hours scheduling. """ + res = self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context) + return res and res[0][0] or False + + def schedule_hours(self, cr, uid, id, hours, day_dt=None, + compute_leaves=False, resource_id=None, + default_interval=None, context=None): + """ Wrapper on _schedule_hours: return the working intervals of an hours + scheduling. """ + return self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context) + + # -------------------------------------------------- + # Days scheduling + # -------------------------------------------------- + + def _schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False, + resource_id=None, default_interval=None, context=None): + """Schedule days of work, using a calendar and an optional resource to + compute working and leave days. This method can be used backwards, i.e. + scheduling days before a deadline. + + :param int days: number of days to schedule. Use a negative number to + compute a backwards scheduling. + :param date day_date: reference date to compute working days. If days is > 0 + date is the starting date. If days is < 0 date is the + ending date. + :param boolean compute_leaves: if set, compute the leaves based on calendar + and resource. Otherwise no leaves are taken + into account. + :param int resource_id: the id of the resource to take into account when + computing the leaves. If not set, only general + leaves are computed. If set, generic and + specific leaves are computed. + :param tuple default_interval: if no id, try to return a default working + day using default_interval[0] as beginning + hour, and default_interval[1] as ending hour. + Example: default_interval = (8, 16). + Otherwise, a void list of working intervals + is returned when id is None. + + :return tuple (datetime, intervals): datetime is the beginning/ending date + of the schedulign; intervals are the + working intervals of the scheduling. + + Implementation note: rrule.rrule is not used because rrule it des not seem + to allow getting back in time. + """ + if day_date is None: + day_date = datetime.datetime.now() + backwards = (days < 0) + days = abs(days) + intervals = [] + planned_days = 0 + iterations = 0 + if backwards: + current_datetime = day_date.replace(hour=23, minute=59, second=59) + else: + current_datetime = day_date.replace(hour=0, minute=0, second=0) + + while planned_days < days and iterations < 1000: + working_intervals = self.get_working_intervals_of_day( + cr, uid, id, current_datetime, + compute_leaves=compute_leaves, resource_id=resource_id, + default_interval=default_interval, + context=context) + if id is None or working_intervals: # no calendar -> no working hours, but day is considered as worked + planned_days += 1 + intervals += working_intervals + # get next day + if backwards: + current_datetime = self.get_previous_day(cr, uid, id, current_datetime, context) + else: + current_datetime = self.get_next_day(cr, uid, id, current_datetime, context) + # avoid infinite loops + iterations += 1 + + return intervals + + def schedule_days_get_date(self, cr, uid, id, days, day_date=None, compute_leaves=False, + resource_id=None, default_interval=None, context=None): + """ Wrapper on _schedule_days: return the beginning/ending datetime of + a days scheduling. """ + res = self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context) + return res and res[-1][1] or False + + def schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False, + resource_id=None, default_interval=None, context=None): + """ Wrapper on _schedule_days: return the working intervals of a days + scheduling. """ + return self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context) + + # -------------------------------------------------- + # Compatibility / to clean / to remove + # -------------------------------------------------- + + def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None): + """ Used in hr_payroll/hr_payroll.py + + :deprecated: OpenERP saas-3. Use get_working_hours_of_date instead. Note: + since saas-3, take hour/minutes into account, not just the whole day.""" + if isinstance(day, datetime.datetime): + day = day.replace(hour=0, minute=0) + return self.get_working_hours_of_date(cr, uid, resource_calendar_id.id, start_dt=day, context=None) + + def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False): + """ Schedule hours backwards. Used in mrp_operations/mrp_operations.py. + + :deprecated: OpenERP saas-3. Use schedule_hours instead. Note: since + saas-3, counts leave hours instead of all-day leaves.""" + return self.schedule_hours( + cr, uid, id, hours * -1.0, + day_dt=dt_from.replace(minute=0, second=0), + compute_leaves=True, resource_id=resource, + default_interval=(8, 16) + ) + + def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True): + """ Used in mrp_operations/mrp_operations.py (default parameters) and in + interval_get() + + :deprecated: OpenERP saas-3. Use schedule_hours instead. Note: + Byday was not used. Since saas-3, counts Leave hours instead of all-day leaves.""" + res = {} + for dt_str, hours, calendar_id in date_and_hours_by_cal: + result = self.schedule_hours( + cr, uid, calendar_id, hours, + day_dt=datetime.datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S').replace(minute=0, second=0), + compute_leaves=True, resource_id=resource, + default_interval=(8, 16) + ) + res[(dt_str, hours, calendar_id)] = result + return res + + def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True): + """ Unifier of interval_get_multi. Used in: mrp_operations/mrp_operations.py, + crm/crm_lead.py (res given). + + :deprecated: OpenERP saas-3. Use get_working_hours instead.""" + res = self.interval_get_multi( + cr, uid, [(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)], resource, byday)[(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)] return res def interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource=False): - """ Calculates the Total Working hours based on given start_date to - end_date, If resource id is supplied that it will consider the source - leaves also in calculating the hours. + """ Unused wrapper. - @param dt_from : date start to calculate hours - @param dt_end : date end to calculate hours - @param resource: optional resource id, If given resource leave will be - considered. - - @return : Total number of working hours based dt_from and dt_end and - resource if supplied. - """ + :deprecated: OpenERP saas-3. Use get_working_hours instead.""" return self._interval_hours_get(cr, uid, id, dt_from, dt_to, resource_id=resource) def _interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource_id=False, timezone_from_uid=None, exclude_leaves=True, context=None): - """ Calculates the Total Working hours based on given start_date to - end_date, If resource id is supplied that it will consider the source - leaves also in calculating the hours. + """ Computes working hours between two dates, taking always same hour/minuts. - @param dt_from : date start to calculate hours - @param dt_end : date end to calculate hours - @param resource_id: optional resource id, If given resource leave will be - considered. - @param timezone_from_uid: optional uid, if given we will considerer - working hours in that user timezone - @param exclude_leaves: optionnal, if set to True (default) we will exclude - resource leaves from working hours - @param context: current request context - @return : Total number of working hours based dt_from and dt_end and - resource if supplied. - """ - utc_tz = pytz.timezone('UTC') - local_tz = utc_tz - - if timezone_from_uid: - users_obj = self.pool.get('res.users') - user_timezone = users_obj.browse(cr, uid, timezone_from_uid, context=context).partner_id.tz - if user_timezone: - try: - local_tz = pytz.timezone(user_timezone) - except pytz.UnknownTimeZoneError: - pass # fallback to UTC as local timezone - - def utc_to_local_zone(naive_datetime): - utc_dt = utc_tz.localize(naive_datetime, is_dst=False) - return utc_dt.astimezone(local_tz) - - def float_time_convert(float_val): - factor = float_val < 0 and -1 or 1 - val = abs(float_val) - return (factor * int(math.floor(val)), int(round((val % 1) * 60))) - - # Get slots hours per day - # {day_of_week: [(8, 12), (13, 17), ...], ...} - hours_range_per_weekday = {} - if id: - cr.execute("select dayofweek, hour_from,hour_to from resource_calendar_attendance where calendar_id=%s order by hour_from", (id,)) - for weekday, hour_from, hour_to in cr.fetchall(): - weekday = int(weekday) - hours_range_per_weekday.setdefault(weekday, []) - hours_range_per_weekday[weekday].append((hour_from, hour_to)) - else: - # considering default working hours (Monday -> Friday, 8 -> 12, 13 -> 17) - for weekday in range(5): - hours_range_per_weekday[weekday] = [(8, 12), (13, 17)] - - ## Interval between dt_from - dt_to - ## - ## dt_from dt_to - ## =============|==================|============ - ## [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] - ## - ## [ : start of range - ## ] : end of range - ## - ## case 1: range end before interval start (skip) - ## case 2: range overlap interval start (fit start to internal) - ## case 3: range within interval - ## case 4: range overlap interval end (fit end to interval) - ## case 5: range start after interval end (skip) - - interval_start = utc_to_local_zone(dt_from) - interval_end = utc_to_local_zone(dt_to) - hours_timedelta = timedelta() - - # Get leaves for requested resource - dt_leaves = set([]) - if exclude_leaves and id: - dt_leaves = set(self._get_leaves(cr, uid, id, resource=resource_id)) - - for day in rrule.rrule(rrule.DAILY, dtstart=interval_start, - until=interval_end+timedelta(days=1), - byweekday=hours_range_per_weekday.keys()): - if exclude_leaves and day.strftime('%Y-%m-%d') in dt_leaves: - # XXX: futher improve leave management to allow for partial day leave - continue - for (range_from, range_to) in hours_range_per_weekday.get(day.weekday(), []): - range_from_hour, range_from_min = float_time_convert(range_from) - range_to_hour, range_to_min = float_time_convert(range_to) - daytime_start = local_tz.localize(day.replace(hour=range_from_hour, minute=range_from_min, second=0, tzinfo=None)) - daytime_end = local_tz.localize(day.replace(hour=range_to_hour, minute=range_to_min, second=0, tzinfo=None)) - - # case 1 & 5: time range out of interval - if daytime_end < interval_start or daytime_start > interval_end: - continue - # case 2 & 4: adjust start, end to fit within interval - daytime_start = max(daytime_start, interval_start) - daytime_end = min(daytime_end, interval_end) - - # case 2+, 4+, 3 - hours_timedelta += (daytime_end - daytime_start) - - # return timedelta converted to hours - return (hours_timedelta.days * 24.0 + hours_timedelta.seconds / 3600.0) + :deprecated: OpenERP saas-3. Use get_working_hours instead. Note: since saas-3, + now resets hour/minuts. Now counts leave hours instead of all-day leaves.""" + return self.get_working_hours( + cr, uid, id, dt_from, dt_to, + compute_leaves=(not exclude_leaves), resource_id=resource_id, + default_interval=(8, 16), context=context) class resource_calendar_attendance(osv.osv): @@ -374,6 +683,8 @@ class resource_resource(osv.osv): def generate_resources(self, cr, uid, user_ids, calendar_id, context=None): """ Return a list of Resource Class objects for the resources allocated to the phase. + + NOTE: Used in project/project.py """ resource_objs = {} user_pool = self.pool.get('res.users') @@ -401,6 +712,8 @@ class resource_resource(osv.osv): @param calendar_id : working calendar of the project @param resource_id : resource working on phase/task @param resource_calendar : working calendar of the resource + + NOTE: used in project/project.py, and in generate_resources """ resource_calendar_leaves_pool = self.pool.get('resource.calendar.leaves') leave_list = [] @@ -415,10 +728,10 @@ class resource_resource(osv.osv): ], context=context) leaves = resource_calendar_leaves_pool.read(cr, uid, leave_ids, ['date_from', 'date_to'], context=context) for i in range(len(leaves)): - dt_start = datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S') - dt_end = datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S') + dt_start = datetime.datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S') + dt_end = datetime.datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S') no = dt_end - dt_start - [leave_list.append((dt_start + timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))] + [leave_list.append((dt_start + datetime.timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))] leave_list.sort() return leave_list @@ -426,6 +739,8 @@ class resource_resource(osv.osv): """ Change the format of working calendar from 'Openerp' format to bring it into 'Faces' format. @param calendar_id : working calendar of the project + + NOTE: used in project/project.py """ if not calendar_id: # Calendar is not specified: working days: 24/7 diff --git a/addons/resource/resource_view.xml b/addons/resource/resource_view.xml index 8954adae80a..74f71ecfeea 100644 --- a/addons/resource/resource_view.xml +++ b/addons/resource/resource_view.xml @@ -60,12 +60,13 @@
- - - - - - + + + + + + +
@@ -245,13 +246,17 @@
+ Resource Leaves resource.calendar.leaves tree,form,calendar + - + + +
diff --git a/addons/resource/test/resource.yml b/addons/resource/test/resource.yml index 28604518b7b..b085c0734c4 100644 --- a/addons/resource/test/resource.yml +++ b/addons/resource/test/resource.yml @@ -26,7 +26,7 @@ dt = now - timedelta(days=now.weekday()) for resource in resources: result = calendar_pool.working_hours_on_day(cr, uid, resource.calendar_id, dt, context) - assert result == 9.0, 'Wrong calculation of day work hour availability of the Resource.' + assert result == 9.0, 'Wrong calculation of day work hour availability of the Resource (found %d).' % result - Now, resource "Developer" drafted leave on Thursday in this week. - diff --git a/addons/resource/tests/__init__.py b/addons/resource/tests/__init__.py new file mode 100644 index 00000000000..f22158c04b8 --- /dev/null +++ b/addons/resource/tests/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2013-TODAY OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from openerp.addons.resource.tests import test_resource + +checks = [ + test_resource, +] + +# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/addons/resource/tests/common.py b/addons/resource/tests/common.py new file mode 100644 index 00000000000..74b91abde2e --- /dev/null +++ b/addons/resource/tests/common.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2013-TODAY OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from datetime import datetime + +from openerp.tests import common + + +class TestResourceCommon(common.TransactionCase): + + def setUp(self): + super(TestResourceCommon, self).setUp() + cr, uid = self.cr, self.uid + if not hasattr(self, 'context'): + self.context = {} + + # Usefull models + self.resource_resource = self.registry('resource.resource') + self.resource_calendar = self.registry('resource.calendar') + self.resource_attendance = self.registry('resource.calendar.attendance') + self.resource_leaves = self.registry('resource.calendar.leaves') + + # Some demo data + self.date1 = datetime.strptime('2013-02-12 09:08:07', '%Y-%m-%d %H:%M:%S') # weekday() returns 1, isoweekday() returns 2 + self.date2 = datetime.strptime('2013-02-15 10:11:12', '%Y-%m-%d %H:%M:%S') # weekday() returns 4, isoweekday() returns 5 + # Leave1: 19/02/2013, from 9 to 12, is a day 1 + self.leave1_start = datetime.strptime('2013-02-19 09:00:00', '%Y-%m-%d %H:%M:%S') + self.leave1_end = datetime.strptime('2013-02-19 12:00:00', '%Y-%m-%d %H:%M:%S') + # Leave2: 22/02/2013, from 9 to 15, is a day 4 + self.leave2_start = datetime.strptime('2013-02-22 09:00:00', '%Y-%m-%d %H:%M:%S') + self.leave2_end = datetime.strptime('2013-02-22 15:00:00', '%Y-%m-%d %H:%M:%S') + # Leave3: 25/02/2013 (day0) -> 01/03/2013 (day4) + self.leave3_start = datetime.strptime('2013-02-25 13:00:00', '%Y-%m-%d %H:%M:%S') + self.leave3_end = datetime.strptime('2013-03-01 11:30:00', '%Y-%m-%d %H:%M:%S') + + # Resource data + # Calendar working days: 1 (8-16 -> 8hours), 4 (8-13, 16-23 -> 12hours) + self.calendar_id = self.resource_calendar.create( + cr, uid, { + 'name': 'TestCalendar', + } + ) + self.att1_id = self.resource_attendance.create( + cr, uid, { + 'name': 'Att1', + 'dayofweek': '1', + 'hour_from': 8, + 'hour_to': 16, + 'calendar_id': self.calendar_id, + } + ) + self.att2_id = self.resource_attendance.create( + cr, uid, { + 'name': 'Att2', + 'dayofweek': '4', + 'hour_from': 8, + 'hour_to': 13, + 'calendar_id': self.calendar_id, + } + ) + self.att3_id = self.resource_attendance.create( + cr, uid, { + 'name': 'Att3', + 'dayofweek': '4', + 'hour_from': 16, + 'hour_to': 23, + 'calendar_id': self.calendar_id, + } + ) + self.resource1_id = self.resource_resource.create( + cr, uid, { + 'name': 'TestResource1', + 'resource_type': 'user', + 'time_efficiency': 150.0, + 'calendar_id': self.calendar_id, + } + ) + self.leave1_id = self.resource_leaves.create( + cr, uid, { + 'name': 'GenericLeave', + 'calendar_id': self.calendar_id, + 'date_from': self.leave1_start, + 'date_to': self.leave1_end, + } + ) + self.leave2_id = self.resource_leaves.create( + cr, uid, { + 'name': 'ResourceLeave', + 'calendar_id': self.calendar_id, + 'resource_id': self.resource1_id, + 'date_from': self.leave2_start, + 'date_to': self.leave2_end, + } + ) + self.leave3_id = self.resource_leaves.create( + cr, uid, { + 'name': 'ResourceLeave2', + 'calendar_id': self.calendar_id, + 'resource_id': self.resource1_id, + 'date_from': self.leave3_start, + 'date_to': self.leave3_end, + } + ) + # Some browse data + self.calendar = self.resource_calendar.browse(cr, uid, self.calendar_id) diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py new file mode 100644 index 00000000000..346317449d3 --- /dev/null +++ b/addons/resource/tests/test_resource.py @@ -0,0 +1,445 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2013-TODAY OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta + +from openerp.addons.resource.tests.common import TestResourceCommon + + +class TestResource(TestResourceCommon): + + def test_00_intervals(self): + intervals = [ + ( + datetime.strptime('2013-02-04 09:00:00', '%Y-%m-%d %H:%M:%S'), + datetime.strptime('2013-02-04 11:00:00', '%Y-%m-%d %H:%M:%S') + ), ( + datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), + datetime.strptime('2013-02-04 12:00:00', '%Y-%m-%d %H:%M:%S') + ), ( + datetime.strptime('2013-02-04 11:00:00', '%Y-%m-%d %H:%M:%S'), + datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S') + ), ( + datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), + datetime.strptime('2013-02-04 21:00:00', '%Y-%m-%d %H:%M:%S') + ), ( + datetime.strptime('2013-02-03 08:00:00', '%Y-%m-%d %H:%M:%S'), + datetime.strptime('2013-02-03 10:00:00', '%Y-%m-%d %H:%M:%S') + ), ( + datetime.strptime('2013-02-04 18:00:00', '%Y-%m-%d %H:%M:%S'), + datetime.strptime('2013-02-04 19:00:00', '%Y-%m-%d %H:%M:%S') + ) + ] + + # Test: interval cleaning + cleaned_intervals = self.resource_calendar.interval_clean(intervals) + self.assertEqual(len(cleaned_intervals), 3, 'resource_calendar: wrong interval cleaning') + # First interval: 03, unchanged + self.assertEqual(cleaned_intervals[0][0], datetime.strptime('2013-02-03 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning') + self.assertEqual(cleaned_intervals[0][1], datetime.strptime('2013-02-03 10:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning') + # Second intreval: 04, 08-14, combining 08-12 and 11-14, 09-11 being inside 08-12 + self.assertEqual(cleaned_intervals[1][0], datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning') + self.assertEqual(cleaned_intervals[1][1], datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning') + # Third interval: 04, 17-21, 18-19 being inside 17-21 + self.assertEqual(cleaned_intervals[2][0], datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning') + self.assertEqual(cleaned_intervals[2][1], datetime.strptime('2013-02-04 21:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning') + + # Test: disjoint removal + working_interval = (datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), datetime.strptime('2013-02-04 18:00:00', '%Y-%m-%d %H:%M:%S')) + result = self.resource_calendar.interval_remove_leaves(working_interval, intervals) + self.assertEqual(len(result), 1, 'resource_calendar: wrong leave removal from interval') + # First interval: 04, 14-17 + self.assertEqual(result[0][0], datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + self.assertEqual(result[0][1], datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + + # Test: schedule hours on intervals + result = self.resource_calendar.interval_schedule_hours(cleaned_intervals, 5.5) + self.assertEqual(len(result), 2, 'resource_calendar: wrong hours scheduling in interval') + # First interval: 03, 8-10 untouches + self.assertEqual(result[0][0], datetime.strptime('2013-02-03 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + self.assertEqual(result[0][1], datetime.strptime('2013-02-03 10:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + # First interval: 04, 08-11:30 + self.assertEqual(result[1][0], datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + self.assertEqual(result[1][1], datetime.strptime('2013-02-04 11:30:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + + # Test: schedule hours on intervals, backwards + cleaned_intervals.reverse() + result = self.resource_calendar.interval_schedule_hours(cleaned_intervals, 5.5, remove_at_end=False) + self.assertEqual(len(result), 2, 'resource_calendar: wrong hours scheduling in interval') + # First interval: 03, 8-10 untouches + self.assertEqual(result[0][0], datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + self.assertEqual(result[0][1], datetime.strptime('2013-02-04 21:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + # First interval: 04, 08-11:30 + self.assertEqual(result[1][0], datetime.strptime('2013-02-04 12:30:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + self.assertEqual(result[1][1], datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval') + + def test_10_calendar_basics(self): + """ Testing basic method of resource.calendar """ + cr, uid = self.cr, self.uid + + # -------------------------------------------------- + # Test1: get_next_day + # -------------------------------------------------- + + # Test: next day: next day after day1 is day4 + date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date1.date()) + self.assertEqual(date, self.date2.date(), 'resource_calendar: wrong next day computing') + + # Test: next day: next day after day4 is (day1+7) + date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date2.date()) + self.assertEqual(date, self.date1.date() + relativedelta(days=7), 'resource_calendar: wrong next day computing') + + # Test: next day: next day after day4+1 is (day1+7) + date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date2.date() + relativedelta(days=1)) + self.assertEqual(date, self.date1.date() + relativedelta(days=7), 'resource_calendar: wrong next day computing') + + # Test: next day: next day after day1-1 is day1 + date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date1.date() + relativedelta(days=-1)) + self.assertEqual(date, self.date1.date(), 'resource_calendar: wrong next day computing') + + # -------------------------------------------------- + # Test2: get_previous_day + # -------------------------------------------------- + + # Test: previous day: previous day before day1 is (day4-7) + date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date1.date()) + self.assertEqual(date, self.date2.date() + relativedelta(days=-7), 'resource_calendar: wrong previous day computing') + + # Test: previous day: previous day before day4 is day1 + date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date2.date()) + self.assertEqual(date, self.date1.date(), 'resource_calendar: wrong previous day computing') + + # Test: previous day: previous day before day4+1 is day4 + date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date2.date() + relativedelta(days=1)) + self.assertEqual(date, self.date2.date(), 'resource_calendar: wrong previous day computing') + + # Test: previous day: previous day before day1-1 is (day4-7) + date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date1.date() + relativedelta(days=-1)) + self.assertEqual(date, self.date2.date() + relativedelta(days=-7), 'resource_calendar: wrong previous day computing') + + # -------------------------------------------------- + # Test3: misc + # -------------------------------------------------- + + weekdays = self.resource_calendar.get_weekdays(cr, uid, self.calendar_id) + self.assertEqual(weekdays, [1, 4], 'resource_calendar: wrong weekdays computing') + + attendances = self.resource_calendar.get_attendances_for_weekdays(cr, uid, self.calendar_id, [2, 3, 4, 5]) + self.assertEqual(set([att.id for att in attendances]), set([self.att2_id, self.att3_id]), + 'resource_calendar: wrong attendances filtering by weekdays computing') + + def test_20_calendar_working_intervals(self): + """ Testing working intervals computing method of resource.calendar """ + cr, uid = self.cr, self.uid + _format = '%Y-%m-%d %H:%M:%S' + + # Test: day0 without leaves: 1 interval + intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date1) + self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-12 09:08:07', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong working intervals') + + # Test: day3 without leaves: 2 interval + intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date2) + self.assertEqual(len(intervals), 2, 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-15 10:11:12', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-15 13:00:00', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[1][0], datetime.strptime('2013-02-15 16:00:00', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[1][1], datetime.strptime('2013-02-15 23:00:00', _format), 'resource_calendar: wrong working intervals') + + # Test: day0 with leaves outside range: 1 interval + intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date1.replace(hour=0), compute_leaves=True) + self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-12 08:00:00', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong working intervals') + + # Test: day0 with leaves: 2 intervals because of leave between 9 ans 12, ending at 15:45:30 + intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, + start_dt=self.date1.replace(hour=8) + relativedelta(days=7), + end_dt=self.date1.replace(hour=15, minute=45, second=30) + relativedelta(days=7), + compute_leaves=True) + self.assertEqual(len(intervals), 2, 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-19 08:08:07', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-19 09:00:00', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[1][0], datetime.strptime('2013-02-19 12:00:00', _format), 'resource_calendar: wrong working intervals') + self.assertEqual(intervals[1][1], datetime.strptime('2013-02-19 15:45:30', _format), 'resource_calendar: wrong working intervals') + + def test_30_calendar_working_days(self): + """ Testing calendar hours computation on a working day """ + cr, uid = self.cr, self.uid + _format = '%Y-%m-%d %H:%M:%S' + + # Test: day1, beginning at 10:30 -> work from 10:30 (arrival) until 16:00 + intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date1.replace(hour=10, minute=30, second=0)) + self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-12 10:30:00', _format), 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong working interval / day computing') + # Test: hour computation for same interval, should give 5.5 + wh = self.resource_calendar.get_working_hours_of_date(cr, uid, self.calendar_id, start_dt=self.date1.replace(hour=10, minute=30, second=0)) + self.assertEqual(wh, 5.5, 'resource_calendar: wrong working interval / day time computing') + + # Test: day1+7 on leave, without leave computation + intervals = self.resource_calendar.get_working_intervals_of_day( + cr, uid, self.calendar_id, + start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=7) + ) + # Result: day1 (08->16) + self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working interval/day computing') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong working interval / day computing') + + # Test: day1+7 on leave, with generic leave computation + intervals = self.resource_calendar.get_working_intervals_of_day( + cr, uid, self.calendar_id, + start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=7), + compute_leaves=True + ) + # Result: day1 (08->09 + 12->16) + self.assertEqual(len(intervals), 2, 'resource_calendar: wrong working interval/day computing') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-19 09:00:00', _format), 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[1][0], datetime.strptime('2013-02-19 12:00:00', _format), 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[1][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong working interval / day computing') + + # Test: day1+14 on leave, with generic leave computation + intervals = self.resource_calendar.get_working_intervals_of_day( + cr, uid, self.calendar_id, + start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=14), + compute_leaves=True + ) + # Result: day1 (08->16) + self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working interval/day computing') + self.assertEqual(intervals[0][0], datetime.strptime('2013-02-26 08:00:00', _format), 'resource_calendar: wrong working interval / day computing') + self.assertEqual(intervals[0][1], datetime.strptime('2013-02-26 16:00:00', _format), 'resource_calendar: wrong working interval / day computing') + + # Test: day1+14 on leave, with resource leave computation + intervals = self.resource_calendar.get_working_intervals_of_day( + cr, uid, self.calendar_id, + start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=14), + compute_leaves=True, + resource_id=self.resource1_id + ) + # Result: nothing, because on leave + self.assertEqual(len(intervals), 0, 'resource_calendar: wrong working interval/day computing') + + def test_40_calendar_hours_scheduling(self): + """ Testing calendar hours scheduling """ + cr, uid = self.cr, self.uid + _format = '%Y-%m-%d %H:%M:%S' + + # -------------------------------------------------- + # Test0: schedule hours backwards (old interval_min_get) + # Done without calendar + # -------------------------------------------------- + + # Done without calendar + # res = self.resource_calendar.interval_min_get(cr, uid, None, self.date1, 40, resource=False) + # res: (datetime.datetime(2013, 2, 7, 9, 8, 7), datetime.datetime(2013, 2, 12, 9, 8, 7)) + + # -------------------------------------------------- + # Test1: schedule hours backwards (old interval_min_get) + # -------------------------------------------------- + + # res = self.resource_calendar.interval_min_get(cr, uid, self.calendar_id, self.date1, 40, resource=False) + # (datetime.datetime(2013, 1, 29, 9, 0), datetime.datetime(2013, 1, 29, 16, 0)) + # (datetime.datetime(2013, 2, 1, 8, 0), datetime.datetime(2013, 2, 1, 13, 0)) + # (datetime.datetime(2013, 2, 1, 16, 0), datetime.datetime(2013, 2, 1, 23, 0)) + # (datetime.datetime(2013, 2, 5, 8, 0), datetime.datetime(2013, 2, 5, 16, 0)) + # (datetime.datetime(2013, 2, 8, 8, 0), datetime.datetime(2013, 2, 8, 13, 0)) + # (datetime.datetime(2013, 2, 8, 16, 0), datetime.datetime(2013, 2, 8, 23, 0)) + # (datetime.datetime(2013, 2, 12, 8, 0), datetime.datetime(2013, 2, 12, 9, 0)) + + res = self.resource_calendar.schedule_hours(cr, uid, self.calendar_id, -40, day_dt=self.date1.replace(minute=0, second=0)) + # current day, limited at 09:00 because of day_dt specified -> 1 hour + self.assertEqual(res[-1][0], datetime.strptime('2013-02-12 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-1][1], datetime.strptime('2013-02-12 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + # previous days: 5+7 hours / 8 hours / 5+7 hours -> 32 hours + self.assertEqual(res[-2][0], datetime.strptime('2013-02-08 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-2][1], datetime.strptime('2013-02-08 23:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-3][0], datetime.strptime('2013-02-08 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-3][1], datetime.strptime('2013-02-08 13:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-4][0], datetime.strptime('2013-02-05 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-4][1], datetime.strptime('2013-02-05 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-5][0], datetime.strptime('2013-02-01 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-5][1], datetime.strptime('2013-02-01 23:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-6][0], datetime.strptime('2013-02-01 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-6][1], datetime.strptime('2013-02-01 13:00:00', _format), 'resource_calendar: wrong hours scheduling') + # 7 hours remaining + self.assertEqual(res[-7][0], datetime.strptime('2013-01-29 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[-7][1], datetime.strptime('2013-01-29 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + # Compute scheduled hours + td = timedelta() + for item in res: + td += item[1] - item[0] + self.assertEqual(td.total_seconds() / 3600.0, 40.0, 'resource_calendar: wrong hours scheduling') + + # -------------------------------------------------- + # Test2: schedule hours forward (old interval_get) + # -------------------------------------------------- + + # res = self.resource_calendar.interval_get(cr, uid, self.calendar_id, self.date1, 40, resource=False, byday=True) + # (datetime.datetime(2013, 2, 12, 9, 0), datetime.datetime(2013, 2, 12, 16, 0)) + # (datetime.datetime(2013, 2, 15, 8, 0), datetime.datetime(2013, 2, 15, 13, 0)) + # (datetime.datetime(2013, 2, 15, 16, 0), datetime.datetime(2013, 2, 15, 23, 0)) + # (datetime.datetime(2013, 2, 22, 8, 0), datetime.datetime(2013, 2, 22, 13, 0)) + # (datetime.datetime(2013, 2, 22, 16, 0), datetime.datetime(2013, 2, 22, 23, 0)) + # (datetime.datetime(2013, 2, 26, 8, 0), datetime.datetime(2013, 2, 26, 16, 0)) + # (datetime.datetime(2013, 3, 1, 8, 0), datetime.datetime(2013, 3, 1, 9, 0)) + + res = self.resource_calendar.schedule_hours( + cr, uid, self.calendar_id, 40, + day_dt=self.date1.replace(minute=0, second=0) + ) + self.assertEqual(res[0][0], datetime.strptime('2013-02-12 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[1][0], datetime.strptime('2013-02-15 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[1][1], datetime.strptime('2013-02-15 13:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[2][0], datetime.strptime('2013-02-15 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[2][1], datetime.strptime('2013-02-15 23:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[3][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[3][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[4][0], datetime.strptime('2013-02-22 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[4][1], datetime.strptime('2013-02-22 13:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[5][0], datetime.strptime('2013-02-22 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[5][1], datetime.strptime('2013-02-22 23:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[6][0], datetime.strptime('2013-02-26 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[6][1], datetime.strptime('2013-02-26 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + td = timedelta() + for item in res: + td += item[1] - item[0] + self.assertEqual(td.total_seconds() / 3600.0, 40.0, 'resource_calendar: wrong hours scheduling') + + # res = self.resource_calendar.interval_get(cr, uid, self.calendar_id, self.date1, 40, resource=self.resource1_id, byday=True) + # (datetime.datetime(2013, 2, 12, 9, 0), datetime.datetime(2013, 2, 12, 16, 0)) + # (datetime.datetime(2013, 2, 15, 8, 0), datetime.datetime(2013, 2, 15, 13, 0)) + # (datetime.datetime(2013, 2, 15, 16, 0), datetime.datetime(2013, 2, 15, 23, 0)) + # (datetime.datetime(2013, 3, 1, 8, 0), datetime.datetime(2013, 3, 1, 13, 0)) + # (datetime.datetime(2013, 3, 1, 16, 0), datetime.datetime(2013, 3, 1, 23, 0)) + # (datetime.datetime(2013, 3, 5, 8, 0), datetime.datetime(2013, 3, 5, 16, 0)) + # (datetime.datetime(2013, 3, 8, 8, 0), datetime.datetime(2013, 3, 8, 9, 0)) + + res = self.resource_calendar.schedule_hours( + cr, uid, self.calendar_id, 40, + day_dt=self.date1.replace(minute=0, second=0), + compute_leaves=True, + resource_id=self.resource1_id + ) + self.assertEqual(res[0][0], datetime.strptime('2013-02-12 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[1][0], datetime.strptime('2013-02-15 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[1][1], datetime.strptime('2013-02-15 13:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[2][0], datetime.strptime('2013-02-15 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[2][1], datetime.strptime('2013-02-15 23:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[3][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[3][1], datetime.strptime('2013-02-19 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[4][0], datetime.strptime('2013-02-19 12:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[4][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[5][0], datetime.strptime('2013-02-22 08:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[5][1], datetime.strptime('2013-02-22 09:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[6][0], datetime.strptime('2013-02-22 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[6][1], datetime.strptime('2013-02-22 23:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[7][0], datetime.strptime('2013-03-01 11:30:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[7][1], datetime.strptime('2013-03-01 13:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[8][0], datetime.strptime('2013-03-01 16:00:00', _format), 'resource_calendar: wrong hours scheduling') + self.assertEqual(res[8][1], datetime.strptime('2013-03-01 22:30:00', _format), 'resource_calendar: wrong hours scheduling') + td = timedelta() + for item in res: + td += item[1] - item[0] + self.assertEqual(td.total_seconds() / 3600.0, 40.0, 'resource_calendar: wrong hours scheduling') + + # -------------------------------------------------- + # Test3: working hours (old _interval_hours_get) + # -------------------------------------------------- + + # old API: resource without leaves + # res: 2 weeks -> 40 hours + res = self.resource_calendar._interval_hours_get( + cr, uid, self.calendar_id, + self.date1.replace(hour=6, minute=0), + self.date2.replace(hour=23, minute=0) + relativedelta(days=7), + resource_id=self.resource1_id, exclude_leaves=True) + self.assertEqual(res, 40.0, 'resource_calendar: wrong _interval_hours_get compatibility computation') + + # new API: resource without leaves + # res: 2 weeks -> 40 hours + res = self.resource_calendar.get_working_hours( + cr, uid, self.calendar_id, + self.date1.replace(hour=6, minute=0), + self.date2.replace(hour=23, minute=0) + relativedelta(days=7), + compute_leaves=False, resource_id=self.resource1_id) + self.assertEqual(res, 40.0, 'resource_calendar: wrong get_working_hours computation') + + # old API: resource and leaves + # res: 2 weeks -> 40 hours - (3+4) leave hours + res = self.resource_calendar._interval_hours_get( + cr, uid, self.calendar_id, + self.date1.replace(hour=6, minute=0), + self.date2.replace(hour=23, minute=0) + relativedelta(days=7), + resource_id=self.resource1_id, exclude_leaves=False) + self.assertEqual(res, 33.0, 'resource_calendar: wrong _interval_hours_get compatibility computation') + + # new API: resource and leaves + # res: 2 weeks -> 40 hours - (3+4) leave hours + res = self.resource_calendar.get_working_hours( + cr, uid, self.calendar_id, + self.date1.replace(hour=6, minute=0), + self.date2.replace(hour=23, minute=0) + relativedelta(days=7), + compute_leaves=True, resource_id=self.resource1_id) + self.assertEqual(res, 33.0, 'resource_calendar: wrong get_working_hours computation') + + # -------------------------------------------------- + # Test4: misc + # -------------------------------------------------- + + # Test without calendar and default_interval + res = self.resource_calendar.get_working_hours( + cr, uid, None, + self.date1.replace(hour=6, minute=0), + self.date2.replace(hour=23, minute=0), + compute_leaves=True, resource_id=self.resource1_id, + default_interval=(8, 16)) + self.assertEqual(res, 32.0, 'resource_calendar: wrong get_working_hours computation') + + def test_50_calendar_schedule_days(self): + """ Testing calendar days scheduling """ + cr, uid = self.cr, self.uid + _format = '%Y-%m-%d %H:%M:%S' + + # -------------------------------------------------- + # Test1: with calendar + # -------------------------------------------------- + + res = self.resource_calendar.schedule_days_get_date(cr, uid, self.calendar_id, 5, day_date=self.date1) + self.assertEqual(res.date(), datetime.strptime('2013-02-26 00:0:00', _format).date(), 'resource_calendar: wrong days scheduling') + + res = self.resource_calendar.schedule_days_get_date( + cr, uid, self.calendar_id, 5, day_date=self.date1, + compute_leaves=True, resource_id=self.resource1_id) + self.assertEqual(res.date(), datetime.strptime('2013-03-01 00:0:00', _format).date(), 'resource_calendar: wrong days scheduling') + + # -------------------------------------------------- + # Test2: misc + # -------------------------------------------------- + + # Without calendar, should only count days -> 12 -> 16, 5 days with default intervals + res = self.resource_calendar.schedule_days_get_date(cr, uid, None, 5, day_date=self.date1, default_interval=(8, 16)) + self.assertEqual(res, datetime.strptime('2013-02-16 16:00:00', _format), 'resource_calendar: wrong days scheduling') diff --git a/addons/sale/board_sale_view.xml b/addons/sale/board_sale_view.xml index 4839744c6d2..021e0c140af 100644 --- a/addons/sale/board_sale_view.xml +++ b/addons/sale/board_sale_view.xml @@ -28,8 +28,8 @@ - - + + diff --git a/addons/sale/report/sale_report.py b/addons/sale/report/sale_report.py index 88b4b14a36a..5d6a1cd759c 100644 --- a/addons/sale/report/sale_report.py +++ b/addons/sale/report/sale_report.py @@ -31,11 +31,6 @@ class sale_report(osv.osv): _columns = { 'date': fields.date('Date Order', readonly=True), 'date_confirm': fields.date('Date Confirm', readonly=True), - 'year': fields.char('Year', size=4, readonly=True), - 'month': fields.selection([('01', 'January'), ('02', 'February'), ('03', 'March'), ('04', 'April'), - ('05', 'May'), ('06', 'June'), ('07', 'July'), ('08', 'August'), ('09', 'September'), - ('10', 'October'), ('11', 'November'), ('12', 'December')], 'Month', readonly=True), - 'day': fields.char('Day', size=128, readonly=True), 'product_id': fields.many2one('product.product', 'Product', readonly=True), 'product_uom': fields.many2one('product.uom', 'Unit of Measure', readonly=True), 'product_uom_qty': fields.float('# of Qty', readonly=True), @@ -71,9 +66,6 @@ class sale_report(osv.osv): count(*) as nbr, s.date_order as date, s.date_confirm as date_confirm, - to_char(s.date_order, 'YYYY') as year, - to_char(s.date_order, 'MM') as month, - to_char(s.date_order, 'YYYY-MM-DD') as day, s.partner_id as partner_id, s.user_id as user_id, s.company_id as company_id, diff --git a/addons/sale/report/sale_report_view.xml b/addons/sale/report/sale_report_view.xml index 28a6f83936a..14b719ebd7a 100644 --- a/addons/sale/report/sale_report_view.xml +++ b/addons/sale/report/sale_report_view.xml @@ -2,40 +2,14 @@ - - - sale.report.tree - sale.report - - - - - - - - - - - - - - - - - - - - - - sale.report.graph sale.report - - - - + + + + @@ -48,7 +22,7 @@ - + @@ -67,9 +41,9 @@ - - - + + + @@ -79,10 +53,10 @@ Sales Analysis sale.report form - tree,graph + graph - - {'search_default_year':1,'search_default_month':1,'search_default_User':1,'group_by_no_leaf':1,'group_by':[]} + + {'search_default_Sales':1, 'group_by_no_leaf':1,'group_by':[]} This report performs analysis on your quotations and sales orders. Analysis check your sales revenues and sort it by different group criteria (salesman, partner, product, etc.) Use this report to perform analysis on sales not having invoiced yet. If you want to analyse your turnover, you should use the Invoice Analysis report in the Accounting application. diff --git a/addons/sale/sale_view.xml b/addons/sale/sale_view.xml index 60e5c83ecf0..da1ec498e62 100644 --- a/addons/sale/sale_view.xml +++ b/addons/sale/sale_view.xml @@ -28,8 +28,8 @@ sale.order - - + + diff --git a/addons/sale_crm/report/sale_crm_account_invoice_report_view.xml b/addons/sale_crm/report/sale_crm_account_invoice_report_view.xml index 983701d9690..be2629c0c55 100644 --- a/addons/sale_crm/report/sale_crm_account_invoice_report_view.xml +++ b/addons/sale_crm/report/sale_crm_account_invoice_report_view.xml @@ -3,7 +3,9 @@ - + diff --git a/addons/sale_crm/report/sale_report_view.xml b/addons/sale_crm/report/sale_report_view.xml index 0d8243c1275..fb62468ec91 100644 --- a/addons/sale_crm/report/sale_report_view.xml +++ b/addons/sale_crm/report/sale_report_view.xml @@ -1,17 +1,6 @@ - - sale.report.tree.sale.crm - sale.report - - - - - - - - sale.report.search.sale.crm sale.report diff --git a/addons/sale_stock/report/sale_report_view.xml b/addons/sale_stock/report/sale_report_view.xml index 486cf3a7b11..5e5ab042e9a 100644 --- a/addons/sale_stock/report/sale_report_view.xml +++ b/addons/sale_stock/report/sale_report_view.xml @@ -1,17 +1,6 @@ - - sale.report.tree.sale.stock - sale.report - - - - - - - - sale.report.search.sale.stock sale.report diff --git a/addons/stock/board_warehouse_view.xml b/addons/stock/board_warehouse_view.xml index 59e17c420b9..a1f327eab5d 100644 --- a/addons/stock/board_warehouse_view.xml +++ b/addons/stock/board_warehouse_view.xml @@ -6,10 +6,10 @@ report.stock.move.graph report.stock.move - - - - + + + + diff --git a/addons/stock/report/report_stock_move_view.xml b/addons/stock/report/report_stock_move_view.xml index c0e301b6af0..fa578a82309 100644 --- a/addons/stock/report/report_stock_move_view.xml +++ b/addons/stock/report/report_stock_move_view.xml @@ -9,42 +9,13 @@ parent="base.menu_reporting" groups="group_stock_manager"/> - - report.stock.move.tree - report.stock.move - - - - - - - - - - - - - - - - - - - - - - - - - - report.stock.move.graph report.stock.move - - - + + + @@ -93,43 +64,20 @@ Moves Analysis report.stock.move form - tree,graph + graph {'contact_display': 'partner','search_default_done':1,'search_default_year':1, 'search_default_month':1, 'search_default_group_type':1, 'group_by': [], 'group_by_no_leaf':1,} Moves Analysis allows you to easily check and analyse your company stock moves. Use this report when you want to analyse the different routes taken by your products and inventory management performance. - - - report.stock.inventory.tree - report.stock.inventory - - - - - - - - - - - - - - - - - - - report.stock.inventory.graph report.stock.inventory - - - + + + @@ -177,7 +125,7 @@ Inventory Analysis report.stock.inventory form - tree,graph + graph {'contact_display': 'partner', 'search_default_real':1, 'search_default_year':1,'search_default_month':1, 'search_default_location_type_internal':1,'search_default_group_product':1,'group_by':[], 'group_by_no_leaf':1}