diff --git a/business_requirement_deliverable_cost/README.rst b/business_requirement_deliverable_cost/README.rst new file mode 100644 index 000000000..723c5420a --- /dev/null +++ b/business_requirement_deliverable_cost/README.rst @@ -0,0 +1,136 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + + +============================================= +Business Requirement Deliverable Cost Control +============================================= + +Introduction +============ + +This module is part of a set of modules (`Business Requirements +`_) + +This module improves the cost control of the original module with the following: + +* Estimation sales price on resource lines. It provides a simple way to + calculate the sales price of the deliverable based on the necessary + resources lines (see usage). +* Adds ACL for sales price and resource cost confidentiality. +* Creates a tab Cost control in the Business Requirement for simple Gross Profit + control. +* Multi-currency compatible: sales price is valued at currency Pricelist and + converted back to the reporting company currency for the cost control. + +Configuration +============= + +Users +----- + +* **Business Requirement Sales Estimates**: Can See the sales prices in DL and + RL (ideal for sales/presales) +* **Business Requirement Cost Control**: Can See the cost prices for project + profit control (Manager/Finance dept) + +Without Sales Estimate nor Cost Control rights: + +.. figure:: /business_requirement_deliverable_cost/static/img/bus_req_acl1.png + :width: 600 px + :alt: No access to sales or cost control information (Simple user) + +Without Cost Control rights: + +.. figure:: /business_requirement_deliverable_cost/static/img/bus_req_acl2.png + :width: 600 px + :alt: Access to sales price with no cost control (Salesmen) + + +With both Sales Estimate and Cost Control rights: + +.. figure:: /business_requirement_deliverable_cost/static/img/bus_req_acl3.png + :width: 600 px + :alt: Full access to sales price and cost control (Financial dept) + + +Estimation Pricelist +-------------------- + +You can define the Estimation price list in the Master Project which will be +used in deliverable lines and sales price for the resource lines. + + +Usage +===== + +The pricelist stored in the Project/Estimation pricelist field will be used to +help the calculation of the expected revenue of a Deliverable based on the sum +of related RL. + +#. In the BR, you can add as many deliverable lines as necessary. You can keep + the price empty at that stage. + +#. Once the deliverable lines are created you can create as many resources lines + as necessary in each DL. + +#. in RL, the estimation sales price will be display per resource. + +#. The total Revenue from the resources (sum of the sales estimation for all RL) + can be manually added back to the deliverable line. + +#. you can review the cost control tab of your BR as followed (only available for + ACL Cost Control) + +#. Eventually you can manually update the price of all resource clicking on the + Update button. + +.. figure:: /business_requirement_deliverable_cost/static/img/bus_req_control.png + :width: 600 px + :alt: Control your cost for the BR + + +.. figure:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/222/9.0 + +Known issues / Roadmap +====================== + +* Display the currency in the cost control panel and deliverable + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Contributors +------------ + +* Eric Caudal +* Alex Duan +* Xie XiaoPeng +* Luke Zheng +* Victor Martin +* Sudhir P. Arya + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/business_requirement_deliverable_cost/__init__.py b/business_requirement_deliverable_cost/__init__.py new file mode 100644 index 000000000..4e58a96b1 --- /dev/null +++ b/business_requirement_deliverable_cost/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2016 Elico Corp (www.elico-corp.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/business_requirement_deliverable_cost/__openerp__.py b/business_requirement_deliverable_cost/__openerp__.py new file mode 100644 index 000000000..27df99a79 --- /dev/null +++ b/business_requirement_deliverable_cost/__openerp__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# © 2016 Elico Corp (www.elico-corp.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Business Requirement Deliverable Cost Control", + "summary": "Control the cost of your Business Requirements", + "version": "9.0.1.0.1", + 'category': 'Business Requirement Management', + "website": "www.elico-corp.com", + "author": "Elico Corp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "business_requirement_deliverable", + ], + 'image': [ + 'static/description/icon.png', + 'static/img/bus_req_acl1.png', + 'static/img/bus_req_acl2.png', + 'static/img/bus_req_acl3.png', + 'static/img/bus_req_control.png' + ], + "data": [ + "security/business_requirement_deliverable_security.xml", + "views/business.xml", + ], + "application": False, + "installable": True, +} diff --git a/business_requirement_deliverable_cost/models/__init__.py b/business_requirement_deliverable_cost/models/__init__.py new file mode 100644 index 000000000..516b4cdca --- /dev/null +++ b/business_requirement_deliverable_cost/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2016 Elico Corp (https://www.elico-corp.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import business diff --git a/business_requirement_deliverable_cost/models/business.py b/business_requirement_deliverable_cost/models/business.py new file mode 100644 index 000000000..44f59088d --- /dev/null +++ b/business_requirement_deliverable_cost/models/business.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# © 2016 Elico Corp (https://www.elico-corp.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import api, fields, models + + +class BusinessRequirementResource(models.Model): + _inherit = "business.requirement.resource" + + sale_price_unit = fields.Float( + string='Sales Price', + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_estimation', + ) + sale_price_total = fields.Float( + compute='_compute_sale_price_total', + string='Total Revenue', + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_estimation', + ) + unit_price = fields.Float( + string='Cost Price', + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_cost_control', + ) + price_total = fields.Float( + store=False, + compute='_compute_get_price_total', + string='Total Cost', + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_cost_control', + ) + partner_id = fields.Many2one( + 'res.partner', + related='business_requirement_deliverable_id.' + 'business_requirement_id.partner_id', + string='Parter ID Related', + readonly=True, + ) + + @api.multi + @api.depends('unit_price', 'qty') + def _compute_get_price_total(self): + for resource in self: + resource.price_total = resource.unit_price * resource.qty + + @api.multi + @api.depends('sale_price_unit', 'qty') + def _compute_sale_price_total(self): + for resource in self: + resource.sale_price_total = resource.sale_price_unit * resource.qty + + @api.multi + def _get_pricelist(self): + self.ensure_one() + partner = self.partner_id + if partner: + if partner.property_product_pricelist: + return partner.property_product_pricelist + else: + return False + + @api.multi + @api.onchange('product_id') + def product_id_change(self): + super(BusinessRequirementResource, self).product_id_change() + unit_price = self.product_id.standard_price + pricelist_id = self._get_pricelist() + partner = self.partner_id + sale_price_unit = self.product_id.list_price + if pricelist_id and partner and self.uom_id: + product = self.product_id.with_context( + lang=partner.lang, + partner=partner.id, + quantity=self.qty, + pricelist=pricelist_id.id, + uom=self.uom_id.id, + ) + sale_price_unit = product.list_price + unit_price = product.standard_price + + self.unit_price = unit_price + self.sale_price_unit = sale_price_unit + + @api.multi + @api.onchange('uom_id', 'qty') + def product_uom_change(self): + qty_uom = 0 + unit_price = self.unit_price + sale_price_unit = self.product_id.list_price + pricelist = self._get_pricelist() + partner = self.partner_id + product_uom = self.env['product.uom'] + + if self.qty != 0: + qty_uom = product_uom._compute_qty( + self.uom_id.id, + self.qty, + self.product_id.uom_id.id + ) / self.qty + + if pricelist: + product = self.product_id.with_context( + lang=partner.lang, + partner=partner.id, + quantity=self.qty, + pricelist=pricelist.id, + uom=self.uom_id.id, + ) + unit_price = product.standard_price + sale_price_unit = product.list_price + + self.unit_price = unit_price * qty_uom + self.sale_price_unit = sale_price_unit * qty_uom + + +class BusinessRequirementDeliverable(models.Model): + _inherit = "business.requirement.deliverable" + + unit_price = fields.Float( + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_estimation', + ) + price_total = fields.Float( + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_estimation', + ) + + @api.multi + def action_button_update_estimation(self): + for deliverable in self: + if deliverable.resource_ids: + for resource in deliverable.resource_ids: + pricelist_id = resource._get_pricelist() + partner = resource.partner_id + resource.sale_price_unit = resource.product_id.lst_price + if pricelist_id and partner and resource.uom_id: + product = resource.product_id.with_context( + lang=partner.lang, + partner=partner.id, + quantity=resource.qty, + pricelist=pricelist_id.id, + uom=resource.uom_id.id, + ) + resource.sale_price_unit = product.price + + +class BusinessRequirement(models.Model): + _inherit = "business.requirement" + + total_revenue = fields.Float( + store=False, + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_estimation', + ) + resource_task_total = fields.Float( + compute='_compute_resource_task_total', + string='Total tasks', + store=False, + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_cost_control', + ) + resource_procurement_total = fields.Float( + compute='_compute_resource_procurement_total', + string='Total procurement', + store=False, + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_cost_control', + ) + gross_profit = fields.Float( + string='Estimated Gross Profit', + compute='_compute_gross_profit', + groups='business_requirement_deliverable_cost.' + 'group_business_requirement_cost_control', + ) + + @api.multi + @api.depends('deliverable_lines') + def _compute_resource_task_total(self): + for br in self: + if br.deliverable_lines: + br.resource_task_total = sum( + br.mapped('deliverable_lines').mapped( + 'resource_ids').filtered( + lambda r: r.resource_type == 'task').mapped( + 'price_total')) + + @api.multi + @api.depends('deliverable_lines') + def _compute_resource_procurement_total(self): + for br in self: + if br.deliverable_lines: + br.resource_procurement_total = sum( + br.mapped('deliverable_lines').mapped( + 'resource_ids').filtered( + lambda r: r.resource_type == 'procurement').mapped( + 'price_total')) + + @api.multi + @api.depends( + 'total_revenue', + 'resource_task_total', + 'resource_procurement_total') + def _compute_gross_profit(self): + for br in self: + br.gross_profit = br.total_revenue - \ + br.resource_task_total - br.resource_procurement_total diff --git a/business_requirement_deliverable_cost/security/business_requirement_deliverable_security.xml b/business_requirement_deliverable_cost/security/business_requirement_deliverable_security.xml new file mode 100644 index 000000000..6e5c0318f --- /dev/null +++ b/business_requirement_deliverable_cost/security/business_requirement_deliverable_security.xml @@ -0,0 +1,25 @@ + + + + + + Business Requirement Estimation + + + + + + + Business Requirement Cost control + + + + + + + diff --git a/business_requirement_deliverable_cost/static/description/icon.png b/business_requirement_deliverable_cost/static/description/icon.png new file mode 100644 index 000000000..0b67a2582 Binary files /dev/null and b/business_requirement_deliverable_cost/static/description/icon.png differ diff --git a/business_requirement_deliverable_cost/static/img/bus_req_acl1.png b/business_requirement_deliverable_cost/static/img/bus_req_acl1.png new file mode 100644 index 000000000..043da8f3d Binary files /dev/null and b/business_requirement_deliverable_cost/static/img/bus_req_acl1.png differ diff --git a/business_requirement_deliverable_cost/static/img/bus_req_acl2.png b/business_requirement_deliverable_cost/static/img/bus_req_acl2.png new file mode 100644 index 000000000..7b0140d47 Binary files /dev/null and b/business_requirement_deliverable_cost/static/img/bus_req_acl2.png differ diff --git a/business_requirement_deliverable_cost/static/img/bus_req_acl3.png b/business_requirement_deliverable_cost/static/img/bus_req_acl3.png new file mode 100644 index 000000000..cb60008d0 Binary files /dev/null and b/business_requirement_deliverable_cost/static/img/bus_req_acl3.png differ diff --git a/business_requirement_deliverable_cost/static/img/bus_req_control.png b/business_requirement_deliverable_cost/static/img/bus_req_control.png new file mode 100644 index 000000000..a46d362d6 Binary files /dev/null and b/business_requirement_deliverable_cost/static/img/bus_req_control.png differ diff --git a/business_requirement_deliverable_cost/tests/__init__.py b/business_requirement_deliverable_cost/tests/__init__.py new file mode 100644 index 000000000..1cea959ad --- /dev/null +++ b/business_requirement_deliverable_cost/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2016 Elico Corp (https://www.elico-corp.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_br diff --git a/business_requirement_deliverable_cost/tests/test_br.py b/business_requirement_deliverable_cost/tests/test_br.py new file mode 100644 index 000000000..ec54a51e9 --- /dev/null +++ b/business_requirement_deliverable_cost/tests/test_br.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +# © 2016 Elico Corp (https://www.elico-corp.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp.tests import common + + +class BusinessRequirementTestCase(common.TransactionCase): + def setUp(self): + super(BusinessRequirementTestCase, self).setUp() + self.ModelDataObj = self.env['ir.model.data'] + + # Configure unit of measure. + self.categ_wtime = self.ModelDataObj.xmlid_to_res_id( + 'product.uom_categ_wtime') + self.categ_kgm = self.ModelDataObj.xmlid_to_res_id( + 'product.product_uom_categ_kgm') + self.UomObj = self.env['product.uom'] + self.uom_hours = self.UomObj.create({ + 'name': 'Test-Hours', + 'category_id': self.categ_wtime, + 'factor': 8, + 'uom_type': 'smaller'}) + self.uom_days = self.UomObj.create({ + 'name': 'Test-Days', + 'category_id': self.categ_wtime, + 'factor': 1}) + self.uom_kg = self.UomObj.create({ + 'name': 'Test-KG', + 'category_id': self.categ_kgm, + 'factor_inv': 1, + 'factor': 1, + 'uom_type': 'reference', + 'rounding': 0.000001}) + # Product Created A, B, C, D + self.ProductObj = self.env['product.product'] + self.productA = self.ProductObj.create( + {'name': 'Product A', 'uom_id': self.uom_hours.id, + 'lst_price': 1000, 'uom_po_id': self.uom_hours.id}) + self.productB = self.ProductObj.create( + {'name': 'Product B', 'uom_id': self.uom_hours.id, + 'lst_price': 3000, 'uom_po_id': self.uom_hours.id}) + self.productC = self.ProductObj.create( + {'name': 'Product C', 'uom_id': self.uom_days.id, + 'uom_po_id': self.uom_days.id}) + self.productD = self.ProductObj.create( + {'name': 'Product D', 'uom_id': self.uom_kg.id, + 'uom_po_id': self.uom_kg.id}) + + self.pricelistA = self.env['product.pricelist'].create({ + 'name': 'Pricelist A', + 'type': 'sale', + 'version_id': [ + (0, 0, { + 'name': 'Version A', + 'items_id': [(0, 0, { + 'name': 'Item A', + 'product_id': self.productA.id, + 'price_discount': '-0.5', + })] + }) + ] + }) + self.project = self.env['project.project'].create({ + 'name': 'Project A', 'pricelist_id': self.pricelistA.id, + 'partner_id': 3, + }) + vals = { + 'description': 'test', + 'project_id': self.project.id, + 'partner_id': 3, + 'deliverable_lines': [ + (0, 0, {'name': 'deliverable line1', 'qty': 1.0, + 'unit_price': 900, 'uom_id': 1, + 'resource_ids': [ + (0, 0, { + 'name': 'Resource Line2', + 'product_id': self.productA.id, + 'qty': 100, + 'uom_id': self.uom_hours.id, + 'unit_price': 500, + 'resource_type': 'task', + }), + (0, 0, { + 'name': 'Resource Line1', + 'product_id': self.productA.id, + 'qty': 100, + 'uom_id': self.uom_hours.id, + 'unit_price': 500, + 'resource_type': 'task', + 'sale_price_unit': 400, + }), + (0, 0, { + 'name': 'Resource Line3', + 'product_id': self.productA.id, + 'qty': 100, + 'uom_id': self.uom_hours.id, + 'unit_price': 500, + 'resource_type': 'procurement', + 'sale_price_unit': 100, + }), + ] + }), + (0, 0, {'name': 'deliverable line2', 'qty': 1.0, + 'unit_price': 1100, 'uom_id': 1}), + (0, 0, {'name': 'deliverable line3', 'qty': 1.0, + 'unit_price': 1300, 'uom_id': 1}), + (0, 0, {'name': 'deliverable line4', 'qty': 1.0, + 'unit_price': 1500, 'uom_id': 1, + }), + ], + } + self.br = self.env['business.requirement'].create(vals) + + def test_compute_sale_price_total(self): + """ Checks if the _compute_sale_price_total works properly + """ + resource = self.env['business.requirement.resource'].search([ + ('name', '=', 'Resource Line1')]) + self.assertEqual( + resource.sale_price_total, 40000) + + def test_product_id_change(self): + """ Checks if the product_id_change works properly + """ + resource = self.env['business.requirement.resource'].search([ + ('name', '=', 'Resource Line1')]) + resource.product_id_change() + # should be ammend + + unit_price = resource.product_id.standard_price + pricelist_id = resource._get_pricelist() + partner = resource.partner_id + sale_price_unit = resource.product_id.list_price + if pricelist_id and partner and resource.uom_id: + product = resource.product_id.with_context( + lang=partner.lang, + partner=partner.id, + quantity=resource.qty, + pricelist=pricelist_id.id, + uom=resource.uom_id.id, + ) + sale_price_unit = product.list_price + unit_price = product.standard_price + + self.assertEqual( + resource.unit_price, unit_price) + self.assertEqual( + resource.sale_price_unit, sale_price_unit) + + def test_compute_resource_task_total(self): + """ Checks if the _compute_resource_task_total works properly + """ + self.assertEqual( + self.br.resource_task_total, 100000.0) + + def test_compute_resource_procurement_total(self): + """ Checks if the _compute_resource_procurement_total works properly + """ + self.assertEqual( + self.br.resource_procurement_total, 50000.0) + + def test_compute_gross_profit(self): + """ Checks if the _compute_gross_profit works properly + """ + self.assertEqual( + self.br.gross_profit, -145200.00) + + def test_compute_get_price_total(self): + resource = self.env['business.requirement.resource'].search([ + ('name', '=', 'Resource Line1')]) + price_total = resource.unit_price * resource.qty + resource._compute_get_price_total() + self.assertEqual( + resource.price_total, price_total) + + def test_product_uom_change(self): + resource = self.env['business.requirement.resource'].search([ + ('name', '=', 'Resource Line1')]) + resource.product_uom_change() + qty_uom = 0 + unit_price = resource.unit_price + sale_price_unit = resource.product_id.list_price + pricelist = resource._get_pricelist() + partner = resource.partner_id + product_uom = resource.env['product.uom'] + + if resource.qty != 0: + qty_uom = product_uom._compute_qty( + resource.uom_id.id, + resource.qty, + resource.product_id.uom_id.id + ) / resource.qty + + if pricelist: + product = resource.product_id.with_context( + lang=partner.lang, + partner=partner.id, + quantity=resource.qty, + pricelist=pricelist.id, + uom=resource.uom_id.id, + ) + unit_price = product.standard_price + sale_price_unit = product.list_price + + self.unit_price = unit_price * qty_uom + self.sale_price_unit = sale_price_unit * qty_uom + + self.assertEqual( + resource.unit_price, self.unit_price) + self.assertEqual( + resource.sale_price_unit, self.sale_price_unit) + + def test_action_button_update_estimation(self): + deliverable = self.br.deliverable_lines[0] + deliverable.action_button_update_estimation() + if deliverable.resource_ids: + for resource in deliverable.resource_ids: + pricelist_id = resource._get_pricelist() + partner = resource.partner_id + sale_price_unit = resource.product_id.lst_price + if pricelist_id and partner and resource.uom_id: + product = resource.product_id.with_context( + lang=partner.lang, + partner=partner.id, + quantity=resource.qty, + pricelist=pricelist_id.id, + uom=resource.uom_id.id, + ) + sale_price_unit = product.price + + self.assertEqual( + resource.sale_price_unit, sale_price_unit) diff --git a/business_requirement_deliverable_cost/views/business.xml b/business_requirement_deliverable_cost/views/business.xml new file mode 100644 index 000000000..77481758f --- /dev/null +++ b/business_requirement_deliverable_cost/views/business.xml @@ -0,0 +1,58 @@ + + + + + business.requirement.resource.tree + business.requirement.resource + tree + + + + + + + + + + + + + business.requirement.deliverable.form + business.requirement.deliverable + form + + + + +