def get_leaves_day_count(self, from_datetime, to_datetime, calendar=None): """ Return the number of leave days for the resource, taking into account attendances. An optional calendar can be given in case multiple calendars can be used on the resource. """ days_count = 0.0 calendar = calendar or self.resource_calendar_id for day_intervals in calendar._iter_leave_intervals(from_datetime, to_datetime, self.resource_id.id): theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) leave_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) days_count += float_utils.round((leave_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 return days_count
def get_work_days_data(self, from_datetime, to_datetime, calendar=None): days_count = 0.0 total_work_time = timedelta() calendar = calendar or self.resource_calendar_id for day_intervals in calendar._iter_work_intervals( from_datetime, to_datetime, self.resource_id.id, compute_leaves=True): theoric_hours = self.get_day_work_hours_count(day_intervals[0][0].date(), calendar=calendar) work_time = sum((interval[1] - interval[0] for interval in day_intervals), timedelta()) total_work_time += work_time days_count += float_utils.round((work_time.total_seconds() / 3600 / theoric_hours) * 4) / 4 return { 'days': days_count, 'hours': total_work_time.total_seconds() / 3600, }
def get_work_days_data(self, from_datetime, to_datetime, compute_leaves=True, calendar=None, domain=None): """ By default the resource calendar is used, but it can be changed using the `calendar` argument. `domain` is used in order to recognise the leaves to take, None means default value ('time_type', '=', 'leave') Returns a dict {'days': n, 'hours': h} containing the quantity of working time expressed as days and as hours. """ resource = self.resource_id calendar = calendar or self.resource_calendar_id # naive datetimes are made explicit in UTC if not from_datetime.tzinfo: from_datetime = from_datetime.replace(tzinfo=utc) if not to_datetime.tzinfo: to_datetime = to_datetime.replace(tzinfo=utc) # total hours per day: retrieve attendances with one extra day margin, # in order to compute the total hours on the first and last days from_full = from_datetime - timedelta(days=1) to_full = to_datetime + timedelta(days=1) intervals = calendar._attendance_intervals(from_full, to_full, resource) day_total = defaultdict(float) for start, stop, meta in intervals: day_total[start.date()] += (stop - start).total_seconds() / 3600 # actual hours per day if compute_leaves: intervals = calendar._work_intervals(from_datetime, to_datetime, resource, domain) else: intervals = calendar._attendance_intervals(from_datetime, to_datetime, resource) day_hours = defaultdict(float) for start, stop, meta in intervals: day_hours[start.date()] += (stop - start).total_seconds() / 3600 # compute number of days as quarters days = sum( float_utils.round(ROUNDING_FACTOR * day_hours[day] / day_total[day]) / ROUNDING_FACTOR for day in day_hours ) return { 'days': days, 'hours': sum(day_hours.values()), }
def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None, is_refund=False, handle_price_include=True): """ Returns all information required to apply taxes (in self + their children in case of a tax group). We consider the sequence of the parent for group of taxes. Eg. considering letters as taxes and alphabetic order as sequence : [G, B([A, D, F]), E, C] will be computed as [A, D, F, C, E, G] 'handle_price_include' is used when we need to ignore all tax included in price. If False, it means the amount passed to this method will be considered as the base of all computations. RETURN: { 'total_excluded': 0.0, # Total without taxes 'total_included': 0.0, # Total with taxes 'total_void' : 0.0, # Total with those taxes, that don't have an account set 'taxes': [{ # One dict for each tax in self and their children 'id': int, 'name': str, 'amount': float, 'sequence': int, 'account_id': int, 'refund_account_id': int, 'analytic': boolean, }], } """ if not self: company = self.env.company else: company = self[0].company_id # 1) Flatten the taxes. taxes, groups_map = self.flatten_taxes_hierarchy(create_map=True) # 2) Deal with the rounding methods if not currency: currency = company.currency_id # By default, for each tax, tax amount will first be computed # and rounded at the 'Account' decimal precision for each # PO/SO/invoice line and then these rounded amounts will be # summed, leading to the total amount for that tax. But, if the # company has tax_calculation_rounding_method = round_globally, # we still follow the same method, but we use a much larger # precision when we round the tax amount for each line (we use # the 'Account' decimal precision + 5), and that way it's like # rounding after the sum of the tax amounts of each line prec = currency.rounding # In some cases, it is necessary to force/prevent the rounding of the tax and the total # amounts. For example, in SO/PO line, we don't want to round the price unit at the # precision of the currency. # The context key 'round' allows to force the standard behavior. round_tax = False if company.tax_calculation_rounding_method == 'round_globally' else True if 'round' in self.env.context: round_tax = bool(self.env.context['round']) if not round_tax: prec *= 1e-5 # 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. # tax | base | amount | # /\ ---------------------------- # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded # || tax_2 | .. | | # || tax_3 | .. | | # || ... | .. | .. | # ---------------------------- def recompute_base(base_amount, fixed_amount, percent_amount, division_amount): # Recompute the new base amount based on included fixed/percent amounts and the current base amount. # Example: # tax | amount | type | price_include | # ----------------------------------------------- # tax_1 | 10% | percent | t # tax_2 | 15 | fix | t # tax_3 | 20% | percent | t # tax_4 | 10% | division | t # ----------------------------------------------- # if base_amount = 145, the new base is computed as: # (145 - 15) / (1.0 + 30%) * 90% = 130 / 1.3 * 90% = 90 return (base_amount - fixed_amount) / ( 1.0 + percent_amount / 100.0) * (100 - division_amount) / 100 # The first/last base must absolutely be rounded to work in round globally. # Indeed, the sum of all taxes ('taxes' key in the result dictionary) must be strictly equals to # 'price_included' - 'price_excluded' whatever the rounding method. # # Example using the global rounding without any decimals: # Suppose two invoice lines: 27000 and 10920, both having a 19% price included tax. # # Line 1 Line 2 # ----------------------------------------------------------------------- # total_included: 27000 10920 # tax: 27000 / 1.19 = 4310.924 10920 / 1.19 = 1743.529 # total_excluded: 22689.076 9176.471 # # If the rounding of the total_excluded isn't made at the end, it could lead to some rounding issues # when summing the tax amounts, e.g. on invoices. # In that case: # - amount_untaxed will be 22689 + 9176 = 31865 # - amount_tax will be 4310.924 + 1743.529 = 6054.453 ~ 6054 # - amount_total will be 31865 + 6054 = 37919 != 37920 = 27000 + 10920 # # By performing a rounding at the end to compute the price_excluded amount, the amount_tax will be strictly # equals to 'price_included' - 'price_excluded' after rounding and then: # Line 1: sum(taxes) = 27000 - 22689 = 4311 # Line 2: sum(taxes) = 10920 - 2176 = 8744 # amount_tax = 4311 + 8744 = 13055 # amount_total = 31865 + 13055 = 37920 base = currency.round(price_unit * quantity) # For the computation of move lines, we could have a negative base value. # In this case, compute all with positive values and negate them at the end. sign = 1 if currency.is_zero(base): sign = self._context.get('force_sign', 1) elif base < 0: sign = -1 if base < 0: base = -base # Store the totals to reach when using price_include taxes (only the last price included in row) total_included_checkpoints = {} i = len(taxes) - 1 store_included_tax_total = True # Keep track of the accumulated included fixed/percent amount. incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 # Store the tax amounts we compute while searching for the total_excluded cached_tax_amounts = {} if handle_price_include: for tax in reversed(taxes): tax_repartition_lines = ( is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids ).filtered(lambda x: x.repartition_type == "tax") sum_repartition_factor = sum( tax_repartition_lines.mapped("factor")) if tax.include_base_amount: base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 store_included_tax_total = True if tax.price_include or self._context.get( 'force_price_include'): if tax.amount_type == 'percent': incl_percent_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'division': incl_division_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'fixed': incl_fixed_amount += abs( quantity) * tax.amount * sum_repartition_factor else: # tax.amount_type == other (python) tax_amount = tax._compute_amount( base, sign * price_unit, quantity, product, partner) * sum_repartition_factor incl_fixed_amount += tax_amount # Avoid unecessary re-computation cached_tax_amounts[i] = tax_amount # In case of a zero tax, do not store the base amount since the tax amount will # be zero anyway. Group and Python taxes have an amount of zero, so do not take # them into account. if store_included_tax_total and ( tax.amount or tax.amount_type not in ("percent", "division", "fixed")): total_included_checkpoints[i] = base store_included_tax_total = False i -= 1 total_excluded = currency.round( recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount)) # 4) Iterate the taxes in the sequence order to compute missing tax amounts. # Start the computation of accumulated amounts at the total_excluded value. base = total_included = total_void = total_excluded # Flag indicating the checkpoint used in price_include to avoid rounding issue must be skipped since the base # amount has changed because we are currently mixing price-included and price-excluded include_base_amount # taxes. skip_checkpoint = False taxes_vals = [] i = 0 cumulated_tax_included_amount = 0 for tax in taxes: tax_repartition_lines = ( is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids ).filtered(lambda x: x.repartition_type == 'tax') sum_repartition_factor = sum( tax_repartition_lines.mapped('factor')) price_include = self._context.get('force_price_include', tax.price_include) #compute the tax_amount if not skip_checkpoint and price_include and total_included_checkpoints.get( i) is not None and sum_repartition_factor != 0: # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues tax_amount = total_included_checkpoints[i] - ( base + cumulated_tax_included_amount) cumulated_tax_included_amount = 0 else: tax_amount = tax.with_context( force_price_include=False)._compute_amount( base, sign * price_unit, quantity, product, partner) # Round the tax_amount multiplied by the computed repartition lines factor. tax_amount = round(tax_amount, precision_rounding=prec) factorized_tax_amount = round(tax_amount * sum_repartition_factor, precision_rounding=prec) if price_include and total_included_checkpoints.get(i) is None: cumulated_tax_included_amount += factorized_tax_amount # If the tax affects the base of subsequent taxes, its tax move lines must # receive the base tags and tag_ids of these taxes, so that the tax report computes # the right total subsequent_taxes = self.env['account.tax'] subsequent_tags = self.env['account.account.tag'] if tax.include_base_amount: subsequent_taxes = taxes[i + 1:] subsequent_tags = subsequent_taxes.get_tax_tags( is_refund, 'base') # Compute the tax line amounts by multiplying each factor with the tax amount. # Then, spread the tax rounding to ensure the consistency of each line independently with the factorized # amount. E.g: # # Suppose a tax having 4 x 50% repartition line applied on a tax amount of 0.03 with 2 decimal places. # The factorized_tax_amount will be 0.06 (200% x 0.03). However, each line taken independently will compute # 50% * 0.03 = 0.01 with rounding. It means there is 0.06 - 0.04 = 0.02 as total_rounding_error to dispatch # in lines as 2 x 0.01. repartition_line_amounts = [ round(tax_amount * line.factor, precision_rounding=prec) for line in tax_repartition_lines ] total_rounding_error = round(factorized_tax_amount - sum(repartition_line_amounts), precision_rounding=prec) nber_rounding_steps = int( abs(total_rounding_error / currency.rounding)) rounding_error = round( nber_rounding_steps and total_rounding_error / nber_rounding_steps or 0.0, precision_rounding=prec) for repartition_line, line_amount in zip(tax_repartition_lines, repartition_line_amounts): if nber_rounding_steps: line_amount += rounding_error nber_rounding_steps -= 1 taxes_vals.append({ 'id': tax.id, 'name': partner and tax.with_context(lang=partner.lang).name or tax.name, 'amount': sign * line_amount, 'base': round(sign * base, precision_rounding=prec), 'sequence': tax.sequence, 'account_id': tax.cash_basis_transition_account_id.id if tax.tax_exigibility == 'on_payment' else repartition_line.account_id.id, 'analytic': tax.analytic, 'price_include': price_include, 'tax_exigibility': tax.tax_exigibility, 'tax_repartition_line_id': repartition_line.id, 'group': groups_map.get(tax), 'tag_ids': (repartition_line.tag_ids + subsequent_tags).ids, 'tax_ids': subsequent_taxes.ids, }) if not repartition_line.account_id: total_void += line_amount # Affect subsequent taxes if tax.include_base_amount: base += factorized_tax_amount if not price_include: skip_checkpoint = True total_included += factorized_tax_amount i += 1 return { 'base_tags': taxes.mapped(is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids').filtered( lambda x: x.repartition_type == 'base').mapped( 'tag_ids').ids, 'taxes': taxes_vals, 'total_excluded': sign * total_excluded, 'total_included': sign * currency.round(total_included), 'total_void': sign * currency.round(total_void), }