diff --git a/distil/config.py b/distil/config.py index dd24855..c275884 100644 --- a/distil/config.py +++ b/distil/config.py @@ -96,6 +96,12 @@ ODOO_OPTS = [ cfg.StrOpt('object_storage_service_name', default='o1.standard', help='Service name for object storage.'), + cfg.ListOpt('invisible_products', default=['reseller-margin-discount'], + help=("The product list which will be invisible to project " + "users. For example, as a cloud provider we would like " + "to hide the reseller margin for reseller's customer.")), + cfg.FloatOpt('tax_rate', default='0.15', + help='Tax rate for invoicing.'), ] diff --git a/distil/erp/drivers/odoo.py b/distil/erp/drivers/odoo.py index aefc3cd..189c8d1 100644 --- a/distil/erp/drivers/odoo.py +++ b/distil/erp/drivers/odoo.py @@ -82,6 +82,7 @@ class OdooDriver(driver.BaseDriver): self.credit = self.odoo.env['cloud.credit'] self.product_category_mapping = {} + self.product_unit_mapping = {} def is_healthy(self): try: @@ -94,6 +95,7 @@ class OdooDriver(driver.BaseDriver): @cache.memoize def get_products(self, regions=[]): self.product_category_mapping.clear() + self.product_unit_mapping.clear() odoo_regions = [] if not regions: @@ -144,6 +146,7 @@ class OdooDriver(driver.BaseDriver): # Odoo GUI unit = product['default_code'] desc = product['description'] + self.product_unit_mapping[product['id']] = unit prices[actual_region][category.lower()].append( {'name': name, @@ -189,7 +192,11 @@ class OdooDriver(driver.BaseDriver): def _get_invoice_detail(self, invoice_id): """Get invoice details. - Return details in the following format: + Two results will be returned: detail_dict and invisible_cost. + invisible_cost is the total cost of those products which cloud + providers don't want to show in the invoice API. It's a number + and has been revised based on give tax rate. The format of + detail_dict is as below: { 'category': { 'total_cost': xxx, @@ -217,6 +224,13 @@ class OdooDriver(driver.BaseDriver): } """ detail_dict = {} + # NOTE(flwang): To hide some cost like 'reseller_margin_discount', we + # need to get the total amount for those cost/usage and then + # re-calculate the total cost for the monthly cost. + # Because the total cost in the final invoice is got from odoo, so it + # includes tax(GST). So we also need to include tax when re-calculate + # the total cost. + invisible_cost = 0 invoice_lines_ids = self.invoice_line.search( [('invoice_id', '=', invoice_id)] @@ -228,7 +242,12 @@ class OdooDriver(driver.BaseDriver): 'resource_name': line['name'], 'quantity': round(line['quantity'], constants.QUANTITY_DIGITS), 'rate': round(line['price_unit'], constants.RATE_DIGITS), - 'unit': line['uos_id'][1], + # TODO(flwang): We're not exposing some product at all, such + # as the discount product. For those kind of product, using + # NZD as the default. We may have to revisit this part later + # if there is new requirement. + 'unit': self.product_unit_mapping.get(line['product_id'][0], + 'NZD'), 'cost': round(line['price_subtotal'], constants.PRICE_DIGITS) } @@ -236,21 +255,25 @@ class OdooDriver(driver.BaseDriver): if re.match(r"\[.+\].+", product): product = product.split(']')[1].strip() - category = self.product_category_mapping[line['product_id'][0]] + if product in self.conf.odoo.invisible_products: + invisible_cost += -(line_info['cost'] * + (1 + self.conf.odoo.tax_rate)) + else: + category = self.product_category_mapping[line['product_id'][0]] - if category not in detail_dict: - detail_dict[category] = { - 'total_cost': 0, - 'breakdown': collections.defaultdict(list) - } + if category not in detail_dict: + detail_dict[category] = { + 'total_cost': 0, + 'breakdown': collections.defaultdict(list) + } - detail_dict[category]['total_cost'] = round( - (detail_dict[category]['total_cost'] + line_info['cost']), - constants.PRICE_DIGITS - ) - detail_dict[category]['breakdown'][product].append(line_info) + detail_dict[category]['total_cost'] = round( + (detail_dict[category]['total_cost'] + line_info['cost']), + constants.PRICE_DIGITS + ) + detail_dict[category]['breakdown'][product].append(line_info) - return detail_dict + return detail_dict, invisible_cost @cache.memoize def get_invoices(self, start, end, project_id, detailed=False): @@ -319,7 +342,12 @@ class OdooDriver(driver.BaseDriver): if not self.product_category_mapping: self.get_products() - details = self._get_invoice_detail(v['id']) + details, invisible_cost = self._get_invoice_detail(v['id']) + # NOTE(flwang): Revise the total cost based on the + # invisible cost + m = result[v['date_invoice']] + m['total_cost'] = round(m['total_cost'] + invisible_cost, + constants.PRICE_DIGITS) result[v['date_invoice']].update({'details': details}) except Exception as e: LOG.exception( diff --git a/distil/tests/unit/erp/drivers/test_odoo.py b/distil/tests/unit/erp/drivers/test_odoo.py index 992c78a..cd5292d 100644 --- a/distil/tests/unit/erp/drivers/test_odoo.py +++ b/distil/tests/unit/erp/drivers/test_odoo.py @@ -116,6 +116,7 @@ class TestOdooDriver(base.DistilTestCase): odoodriver = odoo.OdooDriver(self.conf) odoodriver.invoice.search.return_value = ['1', '2'] + odoodriver.product_unit_mapping = {1: 'hour'} odoodriver.invoice_line.read.side_effect = [ [ { @@ -138,7 +139,7 @@ class TestOdooDriver(base.DistilTestCase): [ { 'name': 'resource3', - 'quantity': 653.2345, + 'quantity': 3, 'price_unit': 0.123, 'uos_id': [1, 'Gigabyte-hour(s)'], 'price_subtotal': 0.369, @@ -146,10 +147,10 @@ class TestOdooDriver(base.DistilTestCase): }, { 'name': 'resource4', - 'quantity': 4, + 'quantity': 40, 'price_unit': 0.123, 'uos_id': [1, 'Gigabyte-hour(s)'], - 'price_subtotal': 0.492, + 'price_subtotal': 4.92, 'product_id': [1, '[hour] NZ-POR-1.c1.c2r8'] }, { @@ -159,13 +160,21 @@ class TestOdooDriver(base.DistilTestCase): 'uos_id': [1, 'Unit(s)'], "price_subtotal": -0.1, 'product_id': [4, 'cloud-dev-grant'] + }, + { + "name": "Reseller Margin discount", + "quantity": 1, + "price_unit": -1, + 'uos_id': [1, 'Unit(s)'], + "price_subtotal": -1, + 'product_id': [8, 'reseller-margin-discount'] } ] ] odoodriver.odoo.execute.return_value = [ - {'id': 1, 'date_invoice': '2017-03-31', 'amount_total': 0.371, + {'id': 1, 'date_invoice': '2017-03-31', 'amount_total': 0.426, 'state': 'paid'}, - {'id': 2, 'date_invoice': '2017-04-30', 'amount_total': 0.759, + {'id': 2, 'date_invoice': '2017-04-30', 'amount_total': 4.817, 'state': 'open'} ] odoodriver.product_category_mapping = { @@ -182,7 +191,7 @@ class TestOdooDriver(base.DistilTestCase): self.assertEqual( { '2017-03-31': { - 'total_cost': 0.37, + 'total_cost': 0.43, 'status': 'paid', 'details': { 'Compute': { @@ -194,14 +203,14 @@ class TestOdooDriver(base.DistilTestCase): "quantity": 1, "rate": 0.123, "resource_name": "resource1", - "unit": "Gigabyte-hour(s)" + "unit": "hour" }, { "cost": 0.25, "quantity": 2, "rate": 0.123, "resource_name": "resource2", - "unit": "Gigabyte-hour(s)" + "unit": "hour" } ] } @@ -209,7 +218,7 @@ class TestOdooDriver(base.DistilTestCase): } }, '2017-04-30': { - 'total_cost': 0.76, + 'total_cost': 5.97, 'status': 'open', 'details': { "Discounts":{ @@ -218,7 +227,7 @@ class TestOdooDriver(base.DistilTestCase): 'cloud-dev-grant': [ { 'quantity': 1.0, - 'unit': 'Unit(s)', + 'unit': 'NZD', 'cost': -0.1, 'resource_name': 'Development Grant', 'rate': -0.1} @@ -226,22 +235,22 @@ class TestOdooDriver(base.DistilTestCase): } }, 'Compute': { - 'total_cost': 0.86, + 'total_cost': 5.29, 'breakdown': { 'NZ-POR-1.c1.c2r8': [ { "cost": 0.37, - "quantity": 653.235, + "quantity": 3, "rate": 0.123, "resource_name": "resource3", - "unit": "Gigabyte-hour(s)" + "unit": "hour" }, { - "cost": 0.49, - "quantity": 4, + "cost": 4.92, + "quantity": 40, "rate": 0.123, "resource_name": "resource4", - "unit": "Gigabyte-hour(s)" + "unit": "hour" } ] }