diff --git a/sale_order_invoicing_finished_task/README.rst b/sale_order_invoicing_finished_task/README.rst new file mode 100644 index 00000000000..3212e56e8d8 --- /dev/null +++ b/sale_order_invoicing_finished_task/README.rst @@ -0,0 +1,82 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +================================== +Sale Order Invoicing Finished Task +================================== + +The requirement of this module is to give the possibility in the task to indicate if a task is available to invoice or not. This means by default even the task is not finished you could set it as invoiceable. + +As an option you can relate to a Proyect Stage ( ``project.task.type`` ) this control. For example if you want to assign Invoiceable to stage ``Done`` always + +Usage +===== + +To use this module, you need to: + +1. Go to Sales -> Product and create a service product + +2. In the product go to Invoicing tab and select (1) An invocing policy (2) Track + service must be create a task and tack hours (3) Set Invoicing finished task + checkbox and save + + + .. image:: static/description/product_view_invoicefinishedtask.png + + +3. Go to Sales -> Sale orders -> Create a new one. Add a customer y the product + you have created +4. Confirm the sales order, it will create you a proyect and a task +5. Go to the task and you will find a smartbutton called Not invoiceable, when + you press the button you will indicate that the task can be invoiced + + .. image:: static/description/task_view_invoicefinishedtask.png + +6. Optional: if you want to use project stages to control this Go To Proyect -> Settings -> Stage -> You have to set true the field Invoiceable in the stages that you consider are invoiceable. Event to use stages for this functionality you can also set it manually in the task whenever you want. + +You can try it in:' + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/167/10.0 + +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 +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + + +Contributors +------------ + +* Denis Leemann +* Sergio Teruel +* Carlos Dauden + +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/sale_order_invoicing_finished_task/__init__.py b/sale_order_invoicing_finished_task/__init__.py new file mode 100644 index 00000000000..cde864bae21 --- /dev/null +++ b/sale_order_invoicing_finished_task/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/sale_order_invoicing_finished_task/__manifest__.py b/sale_order_invoicing_finished_task/__manifest__.py new file mode 100644 index 00000000000..bc5a236cdc3 --- /dev/null +++ b/sale_order_invoicing_finished_task/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Sergio Teruel +# Copyright 2017 Carlos Dauden +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Sale Order Invoicing Finished Task", + "summary": "Control invoice order lines if his task has been finished", + "version": "10.0.1.0.0", + "category": "Sales", + "website": "https://github.com/OCA/sale-workflow", + "author": "Tecnativa, " + "Camptocamp, " + "Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "sale_timesheet", + ], + "data": [ + "views/product_view.xml", + "views/project_view.xml", + ], +} diff --git a/sale_order_invoicing_finished_task/models/__init__.py b/sale_order_invoicing_finished_task/models/__init__.py new file mode 100644 index 00000000000..27c511ccd68 --- /dev/null +++ b/sale_order_invoicing_finished_task/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import product +from . import project +from . import sale_order diff --git a/sale_order_invoicing_finished_task/models/product.py b/sale_order_invoicing_finished_task/models/product.py new file mode 100644 index 00000000000..77e9ff316aa --- /dev/null +++ b/sale_order_invoicing_finished_task/models/product.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Sergio Teruel +# Copyright 2017 Carlos Dauden +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + invoicing_finished_task = fields.Boolean( + help='Invoice the order lines only when the task is in folded stage', + ) diff --git a/sale_order_invoicing_finished_task/models/project.py b/sale_order_invoicing_finished_task/models/project.py new file mode 100644 index 00000000000..74d5dc8d69b --- /dev/null +++ b/sale_order_invoicing_finished_task/models/project.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Camptocamp SA +# Copyright 2017 Sergio Teruel +# Copyright 2017 Carlos Dauden +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError, UserError + + +class ProjectTaskType(models.Model): + _inherit = 'project.task.type' + + invoiceable = fields.Boolean( + string='Invoiceable', + ) + + +class ProjectTask(models.Model): + _inherit = 'project.task' + + invoiceable = fields.Boolean( + string='Invoiceable', + ) + invoicing_finished_task = fields.Boolean( + related='sale_line_id.product_id.invoicing_finished_task', + readonly=True, + ) + + @api.onchange('stage_id') + def _onchange_stage_id(self): + for task in self: + if task.invoicing_finished_task and \ + task.stage_id.invoiceable and\ + not task.invoiceable: + task.invoiceable = True + + @api.multi + def toggle_invoiceable(self): + for task in self: + # We dont' want to modify when the related SOLine is invoiced + if (not task.sale_line_id or + task.sale_line_id.state in ('done', 'cancel') or + task.sale_line_id.invoice_status in ('invoiced',)): + raise UserError(_("You cannot modify the status if there is " + "no Sale Order Line or if it has been " + "invoiced.")) + task.invoiceable = not task.invoiceable + + @api.multi + def write(self, vals): + for task in self: + if (vals.get('sale_line_id') and + task.sale_line_id.state in ('done', 'cancel')): + raise ValidationError(_('You cannot modify the Sale Order ' + 'Line of the task once it is invoiced') + ) + res = super(ProjectTask, self).write(vals) + # Onchange stage_id field is not triggered with statusbar widget + if 'stage_id' in vals: + self._onchange_stage_id() + return res + + @api.model + def create(self, vals): + SOLine = self.env['sale.order.line'] + so_line = SOLine.browse(vals.get('sale_line_id')) + # We don't want to add a project.task to an already invoiced line + if so_line and so_line.state in ('done', 'cancel'): + raise ValidationError(_('You cannot add a task to and invoiced ' + 'Sale Order Line')) + # Onchange stage_id field is not triggered with statusbar widget + if 'sale_line_id' in vals: + stage = self.env['project.task.type'].browse(vals['stage_id']) + if so_line.product_id.invoicing_finished_task and \ + stage.invoiceable: + vals['invoiceable'] = True + return super(ProjectTask, self).create(vals) diff --git a/sale_order_invoicing_finished_task/models/sale_order.py b/sale_order_invoicing_finished_task/models/sale_order.py new file mode 100644 index 00000000000..1328b535a1e --- /dev/null +++ b/sale_order_invoicing_finished_task/models/sale_order.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Sergio Teruel +# Copyright 2017 Carlos Dauden +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.depends('state', 'order_line.invoice_status', + 'order_line.task_ids.invoiceable') + def _get_invoiced(self): + super(SaleOrder, self)._get_invoiced() + for order in self: + if not all(order.tasks_ids.mapped('invoiceable')): + order.update({ + 'invoice_status': 'no', + }) + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + task_ids = fields.One2many( + comodel_name='project.task', + inverse_name='sale_line_id', + string='Tasks', + ) + + @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', + 'order_id.state', 'task_ids.invoiceable') + def _get_to_invoice_qty(self): + lines = self.filtered( + lambda x: (x.product_id.type == 'service' and + x.product_id.invoicing_finished_task and + x.product_id.track_service == 'task') + ) + for line in lines: + if all(line.task_ids.mapped('invoiceable')): + if line.product_id.invoice_policy == 'order': + line.qty_to_invoice = ( + line.product_uom_qty - line.qty_invoiced) + else: + line.qty_to_invoice = ( + line.qty_delivered - line.qty_invoiced) + else: + line.qty_to_invoice = 0.0 + super(SaleOrderLine, self - lines)._get_to_invoice_qty() diff --git a/sale_order_invoicing_finished_task/static/description/icon.png b/sale_order_invoicing_finished_task/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/sale_order_invoicing_finished_task/static/description/icon.png differ diff --git a/sale_order_invoicing_finished_task/static/description/icon.svg b/sale_order_invoicing_finished_task/static/description/icon.svg new file mode 100644 index 00000000000..89d7794c69a --- /dev/null +++ b/sale_order_invoicing_finished_task/static/description/icon.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/sale_order_invoicing_finished_task/static/description/product_view_invoicefinishedtask.png b/sale_order_invoicing_finished_task/static/description/product_view_invoicefinishedtask.png new file mode 100644 index 00000000000..549c1013814 Binary files /dev/null and b/sale_order_invoicing_finished_task/static/description/product_view_invoicefinishedtask.png differ diff --git a/sale_order_invoicing_finished_task/static/description/task_view_invoicefinishedtask.png b/sale_order_invoicing_finished_task/static/description/task_view_invoicefinishedtask.png new file mode 100644 index 00000000000..1639038b8a0 Binary files /dev/null and b/sale_order_invoicing_finished_task/static/description/task_view_invoicefinishedtask.png differ diff --git a/sale_order_invoicing_finished_task/tests/__init__.py b/sale_order_invoicing_finished_task/tests/__init__.py new file mode 100644 index 00000000000..05fd908bf04 --- /dev/null +++ b/sale_order_invoicing_finished_task/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Sergio Teruel +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_sale_order_invoicing_finished_task diff --git a/sale_order_invoicing_finished_task/tests/test_sale_order_invoicing_finished_task.py b/sale_order_invoicing_finished_task/tests/test_sale_order_invoicing_finished_task.py new file mode 100644 index 00000000000..5eb393965ac --- /dev/null +++ b/sale_order_invoicing_finished_task/tests/test_sale_order_invoicing_finished_task.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Sergio Teruel +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import common +from odoo.exceptions import UserError, ValidationError + + +class TestInvoicefinishedTask(common.SavepointCase): + + def setUp(self): + super(TestInvoicefinishedTask, self).setUp() + + group_manager = self.env.ref('sales_team.group_sale_manager') + self.manager = self.env['res.users'].create({ + 'name': 'Andrew Manager', + 'login': 'manager', + 'email': 'a.m@example.com', + 'signature': '--\nAndreww', + 'notify_email': 'always', + 'groups_id': [(6, 0, [group_manager.id])] + }) + + self.partner = self.env['res.partner'].create({ + 'name': 'Customer - test', + 'customer': True, + }) + self.project = self.env['project.project'].create({ + 'name': "Some test project" + }) + self.stage_new = self.env['project.task.type'].create( + self._prepare_stage_vals()) + self.stage_invoiceable = self.env['project.task.type'].create( + self._prepare_stage_vals(invoiceable_stage=True)) + self.uom_unit = self.env.ref('product.product_uom_unit') + + self.Product = self.env['product.product'] + self.product = self.Product.create(self._prepare_product_vals()) + product_delivery_vals = self._prepare_product_vals() + product_delivery_vals.update({ + 'name': 'Product - Service - Policy delivery - Test', + 'invoice_policy': 'delivery', + }) + self.product_pocily_delivery = self.Product.create( + product_delivery_vals) + + self.sale_order = self.env['sale.order'].create( + self._sale_order_vals(self.product)) + self.sale_order_policy_delivery = self.env['sale.order'].create( + self._sale_order_vals(self.product_pocily_delivery)) + + def _prepare_stage_vals(self, invoiceable_stage=False): + return { + 'name': 'Test Invoiceable', + 'sequence': 5, + 'project_ids': [(6, 0, self.project.ids)], + 'invoiceable': invoiceable_stage, + } + + def _sale_order_vals(self, product): + return { + 'partner_id': self.partner.id, + 'pricelist_id': self.partner.property_product_pricelist.id, + 'order_line': [ + (0, 0, { + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 5, + 'product_uom': product.uom_id.id, + 'price_unit': product.list_price, + }), + ], + } + + def _prepare_product_vals(self): + return { + 'name': 'Product - Service - Test', + 'type': 'service', + 'list_price': 100.00, + 'standard_price': 50.00, + 'invoice_policy': 'order', + 'track_service': 'task', + 'invoicing_finished_task': True, + 'project_id': self.project.id, + } + + def _prepare_timesheet_vals(self, task, unit_amount): + return { + 'name': 'Test Line', + 'project_id': self.project.id, + 'unit_amount': unit_amount, + 'user_id': self.manager.id, + 'task_id': task.id, + } + + def test_invoice_status(self): + self.sale_order.action_confirm() + self.assertEqual(self.sale_order.invoice_status, 'no') + task = self.sale_order.order_line.task_ids + + # Add a timesheet line + self.env['account.analytic.line'].create( + self._prepare_timesheet_vals(task, 5.0)) + self.assertEqual(self.sale_order.invoice_status, 'no') + + # Set task in invoiceable stage + task.stage_id = self.stage_invoiceable.id + task._onchange_stage_id() + self.assertEqual(self.sale_order.invoice_status, 'to invoice') + + # Click on toggle_invoiceable method + task.toggle_invoiceable() + self.assertEqual(self.sale_order.invoice_status, 'no') + + task.toggle_invoiceable() + self.assertEqual(self.sale_order.invoice_status, 'to invoice') + + # Make the invoice + self.sale_order.action_invoice_create() + + # Click on toggle_invoiceable method after the so is invoiced + with self.assertRaises(UserError): + task.toggle_invoiceable() + + self.sale_order.action_done() + with self.assertRaises(ValidationError): + task.write({ + 'sale_line_id': self.sale_order_policy_delivery.order_line.id, + }) + # Try to create a task and link it to so line + with self.assertRaises(ValidationError): + self.env['project.task'].create({ + 'name': 'Other Task', + 'user_id': self.manager.id, + 'project_id': self.project.id, + 'sale_line_id': self.sale_order.order_line.id, + }) + + def test_check_qty_to_invoice(self): + self.sale_order.action_confirm() + task = self.sale_order.order_line.task_ids + # Add a timesheet line + self.env['account.analytic.line'].create( + self._prepare_timesheet_vals(task, 10.5)) + self.assertEqual(self.sale_order.order_line.qty_to_invoice, 0.0) + task.toggle_invoiceable() + self.assertEqual(self.sale_order.order_line.qty_to_invoice, 5.0) + # Set task an invoiceable state + self.sale_order_policy_delivery.action_confirm() + # Add a timesheet line + task_delivery = self.sale_order_policy_delivery.order_line.task_ids + self.env['account.analytic.line'].create( + self._prepare_timesheet_vals(task_delivery, 10.0)) + order = self.sale_order_policy_delivery + order.order_line.task_ids.write({ + 'stage_id': self.stage_invoiceable.id, + }) + self.assertEqual( + self.sale_order_policy_delivery.order_line.qty_to_invoice, 10.0) + + def test_create_task_stage_invoiceable(self): + self.sale_order.action_confirm() + task = self.env['project.task'].create({ + 'name': 'Other Task', + 'user_id': self.manager.id, + 'project_id': self.project.id, + 'sale_line_id': self.sale_order.order_line.id, + 'stage_id': self.stage_invoiceable.id, + }) + self.assertTrue(task.invoiceable) diff --git a/sale_order_invoicing_finished_task/views/product_view.xml b/sale_order_invoicing_finished_task/views/product_view.xml new file mode 100644 index 00000000000..fa8fafb6cbc --- /dev/null +++ b/sale_order_invoicing_finished_task/views/product_view.xml @@ -0,0 +1,18 @@ + + + + + + product.template + + + + + + + + + diff --git a/sale_order_invoicing_finished_task/views/project_view.xml b/sale_order_invoicing_finished_task/views/project_view.xml new file mode 100644 index 00000000000..12d0aafe213 --- /dev/null +++ b/sale_order_invoicing_finished_task/views/project_view.xml @@ -0,0 +1,40 @@ + + + + + + project.task.type + + + + + + + + + + project.task.form.track + project.task + + + + + + +
+ +
+
+
+ +