diff --git a/addons/sale_expense/models/hr_expense_sheet.py b/addons/sale_expense/models/hr_expense_sheet.py index 8df9a2d2a98..7957ea12389 100644 --- a/addons/sale_expense/models/hr_expense_sheet.py +++ b/addons/sale_expense/models/hr_expense_sheet.py @@ -1,4 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import Counter + +from psycopg2.extras import execute_values from odoo import fields, models, _ @@ -15,42 +18,66 @@ class HrExpenseSheet(models.Model): def _get_sale_order_lines(self): """ This method is used to try to find the sale order lines created by expense sheets. - - :return: sale.order.line - :rtype: recordset + It is used to reset the quantities of the sale order lines when the expense sheet is reset. + It uses several shared fields to try to find the sale order lines: + - order_id + - product_id + - product_uom_qty + - sale order line's price_unit (computed from the product_id, then rounded to the currency's rounding) + - name """ - expensed_amls = self.account_move_ids.line_ids.filtered(lambda aml: aml.expense_id.sale_order_id and aml.balance >= 0) + # Get the product account move lines created by an expense + expensed_amls = self.account_move_ids.line_ids.filtered(lambda aml: aml.expense_id.sale_order_id and aml.balance >= 0 and not aml.tax_line_id) if not expensed_amls: return self.env['sale.order.line'] + + # Get the sale orders linked to the related expenses aml_to_so_map = expensed_amls._sale_determine_order() - sale_order_ids = tuple(set(aml_to_so_map[aml.id].id for aml in expensed_amls)) - aml_sol_unit_price_map = dict(expensed_amls.mapped(lambda aml: (aml.id, aml._sale_get_invoice_price(aml_to_so_map[aml.id])))) - product_ids = tuple(expensed_amls.product_id.ids) - quantities = tuple(expensed_amls.mapped('quantity')) - names = tuple(expensed_amls.mapped('name')) + self.env['sale.order.line'].flush_model(['order_id', 'product_id', 'product_uom_qty', 'price_unit', 'name']) + self.env['res.company'].flush_model(['currency_id']) + self.env['res.currency'].flush_model(['rounding']) query = """ - SELECT - DISTINCT ON (sol.order_id, sol.product_id, sol.product_uom_qty, sol.price_unit, sol.name) - sol.order_id, sol.product_id, sol.product_uom_qty, sol.price_unit, sol.name, sol.id - FROM sale_order_line AS sol - WHERE sol.is_expense = TRUE - AND sol.order_id IN %s - AND sol.product_id IN %s - AND sol.product_uom_qty IN %s - AND sol.price_unit IN %s - AND sol.name IN %s - ORDER BY sol.order_id, sol.product_id, sol.product_uom_qty, sol.price_unit, sol.name + WITH aml(key_id, key_count, order_id, product_id, product_uom_qty, price_unit, name) AS (VALUES %s) + SELECT ARRAY_AGG(sol.id ORDER BY sol.id), aml.key_count + FROM aml, + sale_order_line AS sol + JOIN res_company AS company ON sol.company_id = company.id + JOIN res_currency AS company_currency ON company.currency_id = company_currency.id + LEFT JOIN res_currency AS currency ON sol.currency_id = currency.id + WHERE sol.is_expense = TRUE + AND sol.order_id = aml.order_id + AND sol.product_id = aml.product_id + AND sol.product_uom_qty = aml.product_uom_qty + AND sol.name = aml.name + AND ROUND(sol.price_unit::numeric, COALESCE(currency.rounding, company_currency.rounding)::int) + = ROUND(aml.price_unit::numeric, COALESCE(currency.rounding, company_currency.rounding)::int) + GROUP BY aml.key_id, aml.key_count """ - self.env.cr.execute(query, (sale_order_ids, product_ids, quantities, tuple(set(aml_sol_unit_price_map.values())), names)) - potential_sols_map = { - (row['order_id'], row['product_id'], row['product_uom_qty'], row['price_unit'], row['name']): row['id'] - for row in self.env.cr.dictfetchall() - } - expensed_amls_keys = set(expensed_amls.mapped( - lambda aml: (aml.expense_id.sale_order_id.id, aml.product_id.id, aml.quantity, aml_sol_unit_price_map[aml.id], aml.name) - )) - return self.env['sale.order.line'].browse(sol_id for key, sol_id in potential_sols_map.items() if key in expensed_amls_keys) + + # Get the keys used to fetch the corresponding sale order lines, and the number of times they are used + # We need the occurrences count to filter out the sale order lines so that we keep exactly one per expense + expense_keys_counter = Counter(expensed_amls.mapped(lambda aml: ( + aml.expense_id.sale_order_id.id, + aml.product_id.id, + aml.quantity, + aml.currency_id.round(aml._sale_get_invoice_price(aml_to_so_map[aml.id])), + aml.name, + ))) + expensed_amls_keys_and_count = tuple( + (key_id, key_count, *key) for key_id, (key, key_count) in enumerate(expense_keys_counter.items()) + ) + execute_values( + self.env.cr._obj, + query, + expensed_amls_keys_and_count, + ) + + # Filters out the sale order lines so that we only keep one per expense + sol_ids = [] + for all_sol_ids_per_key, expense_count_per_key in self.env.cr.fetchall(): + sol_ids += all_sol_ids_per_key[:expense_count_per_key] + return self.env['sale.order.line'].browse(sol_ids) def _sale_expense_reset_sol_quantities(self): sale_order_lines = self._get_sale_order_lines() diff --git a/addons/sale_expense/tests/test_reinvoice.py b/addons/sale_expense/tests/test_reinvoice.py index 57f62630c55..041afcd67a8 100644 --- a/addons/sale_expense/tests/test_reinvoice.py +++ b/addons/sale_expense/tests/test_reinvoice.py @@ -8,239 +8,411 @@ from odoo.tests import tagged @tagged('-at_install', 'post_install') class TestReInvoice(TestExpenseCommon, TestSaleCommon): + """ + Test that expenses, when linked to a sale order and invoiced are correctly re-invoiced on the sale order. + It should cover the following rules: + - Lines are never grouped together (even if re-invoiced at sale price and with a re-invoice delivered policy) + - When posting the move of an expense, it creates the corresponding SOLs with the correct expense quantity + - The amount of analytic account linked do not impact the quantities on the SOLs + - The quantities ordered and delivered are reset to 0 when: + - the expense sheet move has been reset to draft + - the expense move is reversed + - the expense move has been reset to draft + - As it should be a one-to-one relation between model, we need to ensure that one expense only impacts one SOL + The test tries to cover all the possible combinations of expense and invoicing policies, as well as the different actions + """ @classmethod def setUpClass(cls, chart_template_ref=None): super().setUpClass(chart_template_ref=chart_template_ref) + new_sale_tax, new_purchase_tax = cls.env['account.tax'].create([{ + 'name': 'Tax 12.499%', + 'amount': 12.499, + 'amount_type': 'percent', + 'type_tax_use': tax_type, + 'repartition_line_ids': [ + Command.create({'document_type': 'invoice', 'repartition_type': 'base'}), + Command.create({ + 'document_type': 'invoice', + 'repartition_type': 'tax', + 'account_id': cls.company_data[f'default_account_tax_{tax_type}'].id + }), + Command.create({'document_type': 'refund', 'repartition_type': 'base'}), + Command.create({ + 'document_type': 'refund', + 'repartition_type': 'tax', + 'account_id': cls.company_data[f'default_account_tax_{tax_type}'].id + }), + ], + } for tax_type in ('sale', 'purchase')]) + cls.company_data.update({ 'service_order_sales_price': cls.env['product.product'].with_company(cls.company_data['company']).create({ 'name': 'service_order_sales_price', 'categ_id': cls.company_data['product_category'].id, - 'standard_price': 235.0, - 'list_price': 280.0, + 'standard_price': 0., + 'list_price': 280.39, 'type': 'service', 'weight': 0.01, 'uom_id': cls.env.ref('uom.product_uom_unit').id, 'uom_po_id': cls.env.ref('uom.product_uom_unit').id, - 'default_code': 'FURN_9999', + 'default_code': 'FURN_99991', 'invoice_policy': 'order', 'expense_policy': 'sales_price', - 'taxes_id': [Command.set([])], - 'supplier_taxes_id': [Command.set([])], + 'taxes_id': [Command.set([new_sale_tax.id])], + 'supplier_taxes_id': [Command.set([new_purchase_tax.id])], 'can_be_expensed': True, }), 'service_delivery_sales_price': cls.env['product.product'].with_company(cls.company_data['company']).create({ 'name': 'service_order_sales_price', 'categ_id': cls.company_data['product_category'].id, - 'standard_price': 235.0, - 'list_price': 280.0, + 'standard_price': 0., + 'list_price': 280.39, 'type': 'service', 'weight': 0.01, 'uom_id': cls.env.ref('uom.product_uom_unit').id, 'uom_po_id': cls.env.ref('uom.product_uom_unit').id, - 'default_code': 'FURN_9999', - 'invoice_policy': 'order', + 'default_code': 'FURN_99992', + 'invoice_policy': 'delivery', 'expense_policy': 'sales_price', - 'taxes_id': [Command.set([])], - 'supplier_taxes_id': [Command.set([])], + 'taxes_id': [Command.set([new_sale_tax.id])], + 'supplier_taxes_id': [Command.set([new_purchase_tax.id])], + 'can_be_expensed': True, + }), + 'service_delivery_cost_price': cls.env['product.product'].with_company(cls.company_data['company']).create({ + 'name': 'service_delivery_cost_price', + 'categ_id': cls.company_data['product_category'].id, + 'standard_price': 235.28, + 'list_price': 280.39, + 'type': 'service', + 'weight': 0.01, + 'uom_id': cls.env.ref('uom.product_uom_unit').id, + 'uom_po_id': cls.env.ref('uom.product_uom_unit').id, + 'default_code': 'FURN_99993', + 'invoice_policy': 'delivery', + 'expense_policy': 'cost', + 'taxes_id': [Command.set([new_sale_tax.id])], + 'supplier_taxes_id': [Command.set([new_purchase_tax.id])], + 'can_be_expensed': True, + }), + 'service_order_cost_price': cls.env['product.product'].with_company(cls.company_data['company']).create({ + 'name': 'service_order_cost_price', + 'categ_id': cls.company_data['product_category'].id, + 'standard_price': 235.28, + 'list_price': 280.39, + 'type': 'service', + 'weight': 0.01, + 'uom_id': cls.env.ref('uom.product_uom_unit').id, + 'uom_po_id': cls.env.ref('uom.product_uom_unit').id, + 'default_code': 'FURN_99994', + 'invoice_policy': 'order', + 'expense_policy': 'cost', + 'taxes_id': [Command.set([new_sale_tax.id])], + 'supplier_taxes_id': [Command.set([new_purchase_tax.id])], 'can_be_expensed': True, }), }) - - def test_expenses_reinvoice(self): - """ - Test that expenses are re-invoiced correctly and that the quantity is updated when it has to. - - Lines are never grouped together (even if reinvoced at sale price and with a re-invoice delivered policy) - - When posting an expense, it creates the corresponding sol with the expense quantity - - The quantities ordered and delivered are reset to 0 when: - - the expense sheet is unposted - - the expense move is reversed - - the expense move is reset to draft - """ # create SO line and confirm SO (with only one line) - sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ - 'partner_id': self.partner_a.id, - 'partner_invoice_id': self.partner_a.id, - 'partner_shipping_id': self.partner_a.id, + cls.expense_sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'partner_id': cls.partner_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.id, 'order_line': [Command.create({ 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price', # Using the same name as one of the expense - 'product_id': self.company_data['product_order_sales_price'].id, + 'product_id': cls.company_data['product_order_sales_price'].id, 'product_uom_qty': 3.0, - 'price_unit': self.company_data['product_order_sales_price'].standard_price, + 'price_unit': cls.company_data['product_order_sales_price'].standard_price, })], }) - sale_order.action_confirm() + cls.expense_sale_order.action_confirm() - expense_sheet = self.env['hr.expense.sheet'].create({ + # Create an expense sheet with 6 expenses, covering all the expense & invoicing policies combinaisons + cls.sale_expenses = cls.env['hr.expense'].create([ + { + # exp_order_sale_1 + 'name': 'expense_1 invoicing=order, expense=sales_price', + 'date': '2016-01-01', + 'product_id': cls.company_data['service_order_sales_price'].id, + 'total_amount': 100.34, + 'analytic_distribution': {cls.analytic_account_1.id: 100}, + 'employee_id': cls.expense_employee.id, + 'sale_order_id': cls.expense_sale_order.id, + }, + { + # exp_order_sale_2 + 'name': 'expense_2 invoicing=order, expense=sales_price', + 'date': '2016-01-02', + 'product_id': cls.company_data['service_order_sales_price'].id, + 'total_amount': 100.21, + 'employee_id': cls.expense_employee.id, + 'sale_order_id': cls.expense_sale_order.id, + }, + { + # exp_deliv_sale_3 + 'name': 'expense_3 invoicing=delivery, expense=sales_price', + 'date': '2016-01-03', + 'product_id': cls.company_data['service_delivery_sales_price'].id, + 'total_amount': 10012.49, + 'analytic_distribution': {cls.analytic_account_1.id: 100}, + 'employee_id': cls.expense_employee.id, + 'sale_order_id': cls.expense_sale_order.id, + }, + { + # exp_deliv_sale_4 + 'name': 'expense_4 invoicing=delivery, expense=sales_price', + 'date': '2016-01-03', + 'product_id': cls.company_data['service_delivery_sales_price'].id, + 'analytic_distribution': {cls.analytic_account_1.id: 100}, + 'total_amount': 10012.49, + 'employee_id': cls.expense_employee.id, + 'sale_order_id': cls.expense_sale_order.id, + }, + { + # exp_deliv_cost_5 + 'name': 'expense_5 invoicing=delivery, expense=cost', + 'date': '2016-01-03', + 'product_id': cls.company_data['service_delivery_cost_price'].id, + 'quantity': 5, + 'employee_id': cls.expense_employee.id, + 'sale_order_id': cls.expense_sale_order.id, + }, + { + # exp_order_cost_6 + 'name': 'expense_6 invoicing=order, expense=cost', + 'date': '2016-01-03', + 'product_id': cls.company_data['service_order_cost_price'].id, + 'quantity': 6, + 'employee_id': cls.expense_employee.id, + 'sale_order_id': cls.expense_sale_order.id, + }, + ]).sorted() + cls.sale_expense_sheet = cls.env['hr.expense.sheet'].create({ 'name': 'Reset expense test', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data['default_journal_purchase'].id, + 'employee_id': cls.expense_employee.id, + 'journal_id': cls.company_data['default_journal_purchase'].id, 'accounting_date': '2017-01-01', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_1 invoicing=order, expense=sales_price', - 'date': '2016-01-01', - 'product_id': self.company_data['service_order_sales_price'].id, - 'quantity': 1, - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'employee_id': self.expense_employee.id, - 'sale_order_id': sale_order.id, - }), - Command.create({ - 'name': 'expense_2 invoicing=order, expense=sales_price', - 'date': '2016-01-02', - 'product_id': self.company_data['service_order_sales_price'].id, - 'quantity': 2, - 'employee_id': self.expense_employee.id, - 'sale_order_id': sale_order.id, - }), - Command.create({ - 'name': 'expense_3 invoicing=delivery, expense=sales_price', - 'date': '2016-01-03', - 'product_id': self.company_data['service_delivery_sales_price'].id, - 'quantity': 3, - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'employee_id': self.expense_employee.id, - 'sale_order_id': sale_order.id, - }), - Command.create({ - 'name': 'expense_4 invoicing=delivery, expense=sales_price', - 'date': '2016-01-03', - 'product_id': self.company_data['service_delivery_sales_price'].id, - 'quantity': 4, - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'employee_id': self.expense_employee.id, - 'sale_order_id': sale_order.id, - }), - ], + 'expense_line_ids': [Command.set(cls.sale_expenses.ids)], }) + cls.sale_expense_sheet._do_approve() - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() + def test_expenses_reinvoice_case_1_create_moves(self): + """ + CASE 1: Creation of the expense sheets moves. The sale order lines are created. + """ + # pylint: disable=bad-whitespace + self.sale_expense_sheet.action_sheet_move_create() - # We also need to test "duplicates" aka very similar expenses - expense_sheet_copy = self.env['hr.expense.sheet'].create({ - 'name': 'Reset expense test copy', - 'employee_id': self.expense_employee.id, - 'journal_id': self.company_data['default_journal_purchase'].id, - 'accounting_date': '2017-01-01', - 'expense_line_ids': [ - Command.create({ - 'name': 'expense_3 invoicing=delivery, expense=sales_price copy', - 'date': '2016-01-03', - 'product_id': self.company_data['service_delivery_sales_price'].id, - 'quantity': 3, - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'employee_id': self.expense_employee.id, - 'sale_order_id': sale_order.id, - }), - Command.create({ - 'name': 'expense_4 invoicing=delivery, expense=sales_price copy', - 'date': '2016-01-03', - 'product_id': self.company_data['service_delivery_sales_price'].id, - 'quantity': 4, - 'analytic_distribution': {self.analytic_account_1.id: 100}, - 'employee_id': self.expense_employee.id, - 'sale_order_id': sale_order.id, - }), - ], + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'is_expense': False, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-6] SHEET 1 Lines: created with the correct quantities and linked to the expense + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'is_expense': True, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, # noqa: E272 + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'is_expense': True, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, # noqa: E272 + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, # noqa: E272 + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, # noqa: E272 + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'is_expense': True, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, # noqa: E272 + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'is_expense': True, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, # noqa: E272 + ]) + + def test_expenses_reinvoice_case_2_reset_sheet_to_draft(self): + """ + CASE 2: Reset to draft of the expense sheet, the quantities of the corresponding SOL are set to 0 + """ + # CASE 1 steps + self.sale_expense_sheet.action_sheet_move_create() + + # CASE 2 steps + self.sale_expense_sheet.action_reset_expense_sheets() + + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-6] SHEET Lines: quantities are reset to 0 and expenses are unlinked + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + ]) + + def test_expenses_reinvoice_case_3_recreate_move_after_reset(self): + """ + CASE 3: Re-Approve and Re-Post the expense sheet after a reset, creating new SOLs with the correct quantities + """ + # CASE 1 steps + self.sale_expense_sheet.action_sheet_move_create() + + # CASE 2 steps + self.sale_expense_sheet.action_reset_expense_sheets() + + # CASE 3 steps + self.sale_expense_sheet._do_approve() + self.sale_expense_sheet.action_sheet_move_create() + + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-6] SHEET CASE 2 Lines: no change + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [7-12] SHEET CASE 3 Lines: created with the correct quantities and linked to the expense + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + ]) + + def test_expenses_reinvoice_case_4_reset_sheet_move_to_draft(self): + """ + CASE 4: Reset to draft of the expense sheet's move, the quantities of the corresponding SOL are set to 0 + """ + # CASE 1 steps + self.sale_expense_sheet.action_sheet_move_create() + + # CASE 4 steps + self.sale_expense_sheet.account_move_ids.button_draft() + + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-6] SHEET Lines: quantities are reset to 0 and expenses are unlinked + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + ]) + + def test_expenses_reinvoice_case_5_repost_sheet_move_after_reset_to_draft(self): + """ + CASE 5: Re-Post the expense sheet's move, creating new SOLs with the correct quantities + """ + # CASE 1 steps + self.sale_expense_sheet.action_sheet_move_create() + + # CASE 4 steps + self.sale_expense_sheet.account_move_ids.button_draft() + + # CASE 5 steps + self.sale_expense_sheet.account_move_ids.action_post() + + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-6] SHEET CASE 4 Lines: no change + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [7-12] SHEET CASE 5 Lines: created with the correct quantities and linked to the expense + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + ]) + + def test_expenses_reinvoice_case_6_reverse_expense_move(self): + """ + CASE 6: Reverse the expense sheet's move, the quantities of the corresponding SOL are reset to 0 + """ + # CASE 1 steps + self.sale_expense_sheet.action_sheet_move_create() + + # CASE 6 steps + self.sale_expense_sheet.account_move_ids._reverse_moves() + + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-6] SHEET Lines: quantities are reset to 0 and expenses are unlinked + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + ]) + + def test_expenses_reinvoice_case_7_ensure_one2one_relationship(self): + """ + CASE 7: Test that two exact same sols are not reset to 0 when the expense of one of them is resetting the quantities to 0 + """ + original_expenses = self.sale_expenses + self.sale_expense_sheet.write({ + 'expense_line_ids': [Command.link(expense.copy().id) for expense in original_expenses], # Duplicates of the expenses IN the reset sheet + 'accounting_date': '2017-01-01', # To avoid "duplicate vendor reference raised" in the move }) - - action_data = expense_sheet_copy.action_approve_expense_sheets() - duplicate_wizard = self.env['hr.expense.approve.duplicate'].with_context(action_data['context']).create({}) - duplicate_wizard.action_approve() - expense_sheet_copy.action_sheet_move_create() - #pylint: disable=bad-whitespace - self.assertRecordValues(sale_order.order_line, [ - {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'is_expense': False, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'is_expense': True, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 2.0, 'product_uom_qty': 2.0, 'is_expense': True, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price copy'}, - ]) - - expense_sheet.action_reset_expense_sheets() # Lines [0-4] quantities are set to 0 - self.assertRecordValues(sale_order.order_line, [ + self.sale_expense_sheet._do_approve() + self.sale_expense_sheet.action_sheet_move_create() + sheet_2 = self.sale_expense_sheet.copy({ + 'expense_line_ids': [Command.set([expense.copy().id for expense in original_expenses])], # Duplicates of the expenses OUTSIDE the reset sheet + 'accounting_date': '2017-01-02', + }) + sheet_2._do_approve() + sheet_2.action_sheet_move_create() + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price copy'}, - ]) - - expense_sheet.action_approve_expense_sheets() - expense_sheet.action_sheet_move_create() # Lines [1-4] are still at 0 but new lines are created (stable limitation) - - self.assertRecordValues(sale_order.order_line, [ - {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price copy'}, + # [1-12] SHEET 1 Lines: Created with the correct quantities + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 2.0, 'product_uom_qty': 2.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - ]) - - expense_sheet.account_move_ids.button_draft() # Lines [7-11] quantities are set to 0 - self.assertRecordValues(sale_order.order_line, [ - {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - ]) - - expense_sheet.account_move_ids.action_post() # Lines [7-11] are still at 0 but new lines are created (stable limitation) - self.assertRecordValues(sale_order.order_line, [ - {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [13-18] SHEET 2 Lines: Created with the correct quantities + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 2.0, 'product_uom_qty': 2.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, ]) - expense_sheet.account_move_ids._reverse_moves() # Lines [7-11] quantities are set to 0 - self.assertRecordValues(sale_order.order_line, [ - {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'is_expense': False, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 3.0, 'product_uom_qty': 3.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 4.0, 'product_uom_qty': 4.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price copy'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, - {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'is_expense': True, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + self.sale_expense_sheet.account_move_ids.button_draft() + + self.assertRecordValues(self.expense_sale_order.order_line, [ + # [0] Line not created from a re-invoiced, should never be changed + {'qty_delivered': 0.0, 'product_uom_qty': 3.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [1-12] SHEET 1 Lines: quantities are reset to 0 and expenses are unlinked (because they are the oldest) + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 0.0, 'product_uom_qty': 0.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, + # [13-18] SHEET 2 Lines: Not caught by the reset + {'qty_delivered': 6.0, 'product_uom_qty': 6.0, 'name': 'expense_employee: expense_6 invoicing=order, expense=cost'}, + {'qty_delivered': 5.0, 'product_uom_qty': 5.0, 'name': 'expense_employee: expense_5 invoicing=delivery, expense=cost'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_4 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_3 invoicing=delivery, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_2 invoicing=order, expense=sales_price'}, + {'qty_delivered': 1.0, 'product_uom_qty': 1.0, 'name': 'expense_employee: expense_1 invoicing=order, expense=sales_price'}, ]) def test_expenses_reinvoice_analytic_distribution(self):