[IMP] sale_timesheet: improve invoicing sale order with timesheets

TaskID: 2088586

Add an 'Invoice timesheets up to mm/dd/yyyy' field on the Invoice
Creation modal. This field is only visible when we want to create an
invoice for a Service whose Service Policy is set on 'Timesheets on
tasks'.
This field allows to invoice the timesheets of a service up to the date
set on this field.

Before this commit, when we create the account.moves.line records, we
link timesheets of a service to an invoice. Now, we need to check which
timesheets are invoiced, because with the adding of the date for the
creation of invoices, we cannot invoice the timesheets after to this
date. Then, the process is done at the end of '_create_invoices' in
'sale.order' model when the invoices are created.

Override an ir.rule in accout module, because this rule accept all
account.analytic.line in its domain and we don't want to accept all
timesheets when the user has the access right : "See own timesheets".
Therefore, we need to override this rule to apply the others rules in
this model for the timesheets.
This commit is contained in:
Xavier Bol (xbo)
2019-12-19 10:20:27 +01:00
committed by Yannick Tivisse
parent 3cff5808f9
commit 46d70e30ef
10 changed files with 282 additions and 23 deletions
+1
View File
@@ -29,6 +29,7 @@ have real delivered quantities in sales orders.
'data/sale_timesheet_filters.xml',
'wizard/project_create_sale_order_views.xml',
'wizard/project_create_invoice_views.xml',
'wizard/sale_make_invoice_advance_views.xml',
],
'demo': [
'data/sale_service_demo.xml',
@@ -477,10 +477,10 @@
<field name="sale_line_id" ref="sale_timesheet.sale_line_13"/>
<field name="sale_order_id" ref="sale_timesheet.sale_order_1"/>
<field name="project_id" search="[('sale_order_id', '=', ref('sale_timesheet.sale_order_1'))]" />
<field name="stage_id" ref="project_from_sale_order_1_in_progress"/>
<field name="stage_id" ref="project_from_sale_order_1_in_progress"/>
<field name="sale_line_id" ref="sale_timesheet.sale_line_13" />
<field name="sale_order_id" ref="sale_timesheet.sale_order_1" />
<field name="partner_id" ref="base.res_partner_2" />
<field name="partner_id" ref="base.res_partner_2" />
</record>
<record id="project_task_4" model="project.task">
+20 -21
View File
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools.float_utils import float_round, float_is_zero
from odoo.osv import expression
class AccountMove(models.Model):
@@ -43,30 +43,29 @@ class AccountMove(models.Model):
}
}
def _link_timesheets_to_invoice(self, date=None):
""" Search timesheets and link this timesheets to the invoice
When we create an invoice from a sale order, we need to
link the timesheets in this sale order to the invoice.
Then, we can know which timesheets are invoiced in the sale order.
:param date: All timesheets on task before or equals this date
in the sale order are invoiced.
"""
for line in self.filtered(lambda i: i.type == 'out_invoice' and i.state == 'draft').invoice_line_ids:
sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
if sale_line_delivery:
domain = line._timesheet_domain_get_invoiced_lines(sale_line_delivery)
if date:
domain = expression.AND([domain, [('date', '<=', date)]])
timesheets = self.env['account.analytic.line'].search(domain).sudo()
timesheets.write({'timesheet_invoice_id': line.move_id.id})
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
@api.model_create_multi
def create(self, vals_list):
# OVERRIDE
# Link the timesheet from the SO lines to the corresponding draft invoice.
# NOTE: Only the timesheets linked to an Sale Line with a product invoiced on delivered quantity
# are concerned, since in ordered quantity, the timesheet quantity is not invoiced, but is simply
# to compute the delivered one (for reporting).
lines = super(AccountMoveLine, self).create(vals_list)
lines_to_process = lines.filtered(lambda line: line.move_id.type == 'out_invoice'
and line.move_id.state == 'draft')
for line in lines_to_process:
sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
if sale_line_delivery:
domain = self._timesheet_domain_get_invoiced_lines(sale_line_delivery)
timesheets = self.env['account.analytic.line'].search(domain).sudo()
timesheets.write({
'timesheet_invoice_id': line.move_id.id,
})
return lines
@api.model
def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery):
""" Get the domain for the timesheet to link to the created invoice
+5
View File
@@ -113,3 +113,8 @@ class ProductProduct(models.Model):
self.project_template_id = False
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False
def _is_delivered_timesheet(self):
""" Check if the product is a delivered timesheet """
self.ensure_one()
return self.type == 'service' and self.service_policy == 'delivered_timesheet'
@@ -148,6 +148,19 @@ class SaleOrder(models.Model):
action = {'type': 'ir.actions.act_window_close'}
return action
def _create_invoices(self, grouped=False, final=False, date=None):
""" Override the _create_invoice method in sale.order model in sale module
Add new parameter in this method, to invoice sale.order with a date. This date is used in sale_make_invoice_advance_inv into this module.
:param date
:return {account.move}: the invoices created
"""
moves = super(SaleOrder, self)._create_invoices(grouped, final)
moves._link_timesheets_to_invoice(date)
return moves
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
@@ -389,3 +402,23 @@ class SaleOrderLine(models.Model):
project = map_so_project[so_line.order_id.id]
if not so_line.task_id:
so_line._timesheet_create_task(project=project)
def _recompute_qty_to_invoice(self, date):
""" Recompute the qty_to_invoice field for product containing timesheets
Search the existed timesheets up the given date in parameter.
Retrieve the unit_amount of this timesheet and then recompute
the qty_to_invoice for each current product.
:param date: date to search timesheets before this date.
"""
lines_by_timesheet = self.filtered(lambda sol: sol.product_id._is_delivered_timesheet())
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
domain = expression.AND([domain, [
('date', '<=', date),
('timesheet_invoice_id', '=', False)]])
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
for line in lines_by_timesheet:
if line.product_id._is_delivered_timesheet():
line.qty_to_invoice = mapping.get(line.id, 0.0)
@@ -14,5 +14,14 @@
<field name="perm_read" eval="1"/>
</record>
<!-- Override this rule because in hr_timesheet,
the lowest access right can only see own timesheets (model: account.analytic.line)
and this ir.rule accept all account.analytic.line in its domain.
Therefore, we need to override this rule to change the domain, and then the
rules for the account.analytic.line defined in timesheet will be apply.
-->
<record id="account.account_analytic_line_rule_billing_user" model="ir.rule">
<field name="domain_force">[('project_id', '=', False)]</field>
</record>
</data>
</odoo>
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
from odoo.tools import float_is_zero
from odoo.exceptions import UserError
@@ -21,6 +23,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# set up
cls.setUpEmployees()
cls.setUpServiceProducts()
cls.setUpAdditionalAccounts()
cls.setUpAccountJournal()
def test_timesheet_order(self):
""" Test timesheet invoicing with 'invoice on order' timetracked products
@@ -379,3 +383,138 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice, even after invoice validation")
self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice, even after invoice validation")
def test_timesheet_invoice(self):
""" Test to create invoices for the sale order with timesheets
1) create sale order
2) try to create an invoice for the timesheets 10 days before
3) create invoice for the timesheets 6 days before
4) create invoice for the timesheets 4 days before
5) create invoice for the timesheets from today
"""
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_customer_usd.id,
'partner_invoice_id': self.partner_customer_usd.id,
'partner_shipping_id': self.partner_customer_usd.id,
'pricelist_id': self.pricelist_usd.id,
})
so_line_deliver_global_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet2.name,
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'product_uom': self.product_delivery_timesheet2.uom_id.id,
'price_unit': self.product_delivery_timesheet2.list_price,
'order_id': sale_order.id,
})
so_line_deliver_task_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet3.name,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 20,
'product_uom': self.product_delivery_timesheet3.uom_id.id,
'price_unit': self.product_delivery_timesheet3.list_price,
'order_id': sale_order.id,
})
so_line_deliver_global_project.product_id_change()
so_line_deliver_task_project.product_id_change()
# confirm SO
sale_order.action_confirm()
task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
timesheet1 = self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task_serv1.project_id.id,
'task_id': task_serv1.id,
'unit_amount': 10,
'employee_id': self.employee_manager.id,
'date': date.today() - timedelta(days=6)
})
timesheet2 = self.env['account.analytic.line'].create({
'name': 'Test Line 2',
'project_id': task_serv1.project_id.id,
'task_id': task_serv1.id,
'unit_amount': 20,
'employee_id': self.employee_manager.id,
'date': date.today() - timedelta(days=1)
})
timesheet3 = self.env['account.analytic.line'].create({
'name': 'Test Line 3',
'project_id': task_serv1.project_id.id,
'task_id': task_serv1.id,
'unit_amount': 10,
'employee_id': self.employee_manager.id,
'date': date.today() - timedelta(days=5)
})
timesheet4 = self.env['account.analytic.line'].create({
'name': 'Test Line 4',
'project_id': task_serv2.project_id.id,
'task_id': task_serv2.id,
'unit_amount': 30,
'employee_id': self.employee_manager.id
})
self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice')
self.assertEqual(so_line_deliver_task_project.invoice_status, 'to invoice')
self.assertEqual(sale_order.invoice_status, 'to invoice')
# Context for sale.advance.payment.inv wizard
self.context = {
'active_model': 'sale.order',
'active_ids': [sale_order.id],
'active_id': sale_order.id,
'default_journal_id': self.journal_sale.id
}
# invoice SO
wizard = self.env['sale.advance.payment.inv'].with_context(self.context).create({
'advance_payment_method': 'delivered',
'date_invoice_timesheet': date.today() - timedelta(days=10)
})
self.assertTrue(wizard.invoicing_timesheet_enabled, 'The "date_invoice_timesheet" field should be visible in the wizard because a product in sale order has service_policy to "Timesheet on Task"')
with self.assertRaises(UserError):
wizard.create_invoices()
self.assertFalse(sale_order.invoice_ids, 'Normally, no invoice will be created created because the timesheet logged is after the date defined in date_invoice_timesheet field')
wizard.write({
'date_invoice_timesheet': date.today() - timedelta(days=6)
})
wizard.create_invoices()
self.assertTrue(sale_order.invoice_ids, 'One invoice should be created because the timesheet logged is before or equal to the date defined in wizard')
invoice = sale_order.invoice_ids[0]
self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount)
# validate invoice
invoice.post()
wizard.write({
'date_invoice_timesheet': date.today() - timedelta(days=4)
})
wizard.create_invoices()
self.assertEqual(len(sale_order.invoice_ids), 2)
invoice2 = sale_order.invoice_ids[-1]
self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet3.unit_amount, "The last invoice done should have the quantity of the timesheet 3, because the date this timesheet is the only one before the 'date_invoice_timesheet' field in the wizard.")
wizard.write({
'date_invoice_timesheet': date.today()
})
wizard.create_invoices()
self.assertEqual(len(sale_order.invoice_ids), 3)
invoice3 = sale_order.invoice_ids[-1]
# Check if all timesheets have been invoiced
self.assertEqual(so_line_deliver_global_project.qty_invoiced, timesheet1.unit_amount + timesheet2.unit_amount + timesheet3.unit_amount)
self.assertEqual(so_line_deliver_task_project.qty_invoiced, + timesheet4.unit_amount)
+1
View File
@@ -3,3 +3,4 @@
from . import project_create_sale_order
from . import project_create_invoice
from . import sale_make_invoice_advance
@@ -0,0 +1,51 @@
from odoo import api, fields, models
class SaleAdvancePaymentInv(models.TransientModel):
_inherit = "sale.advance.payment.inv"
@api.model
def _default_invoicing_timesheet_enabled(self):
product_ids = None
if self._context.get('active_id', False):
sale_order = self.env['sale.order'].browse(self._context.get('active_id'))
product_ids = sale_order.order_line.filtered(lambda sol: sol.invoice_status == 'to invoice').mapped('product_id')
elif self._context.get('active_ids', []):
sale_orders = self.env['sale.order'].browse(self._context.get('active_ids'))
order_lines = sale_orders.mapped('order_line').filtered(lambda sol: sol.invoice_status == 'to invoice')
product_ids = order_lines.mapped('product_id')
if product_ids:
return any(product._is_delivered_timesheet() for product in product_ids)
return False
date_invoice_timesheet = fields.Date(
string='Invoice Timesheets Up To This Date',
default=fields.Date.today,
required=False,
help="Only timesheets not yet invoiced (and validated, if applicable) up to this date included will be invoiced. If no date is indicated, all timesheets not yet invoiced (and validated, if applicable) will be invoiced without distinction.")
invoicing_timesheet_enabled = fields.Boolean(default=_default_invoicing_timesheet_enabled)
def create_invoices(self):
""" Override method from sale/wizard/sale_make_invoice_advance.py
When the user want to invoice the timesheets to the SO
up to a specific date then we need to recompute the
qty_to_invoice for each product_id in sale.order.line,
before creating the invoice.
"""
sale_orders = self.env['sale.order'].browse(
self._context.get('active_ids', [])
)
if self.advance_payment_method == 'delivered' and self.invoicing_timesheet_enabled:
if self.date_invoice_timesheet:
sale_orders.mapped('order_line')._recompute_qty_to_invoice(self.date_invoice_timesheet)
sale_orders._create_invoices(final=self.deduct_down_payments, date=self.date_invoice_timesheet)
if self._context.get('open_invoices', False):
return sale_orders.action_view_invoice()
return {'type': 'ir.actions.act_window_close'}
return super(SaleAdvancePaymentInv, self).create_invoices()
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_advance_payment_inv_timesheet_view_form" model="ir.ui.view">
<field name="name">sale_timesheet.sale.advance.payment.inv.view.form</field>
<field name="model">sale.advance.payment.inv</field>
<field name="inherit_id" ref="sale.view_sale_advance_payment_inv"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='deposit_taxes_id']" position="after">
<field name="date_invoice_timesheet" class="oe_inline" string="Invoice Timesheets up to" data-toggle="tooltip" data-placement="bottom"
title="Only timesheets not yet invoiced (and validated, if applicable) up to this date included will be invoiced. If no date is indicated, all timesheets not yet invoiced (and validated, if applicable) will be invoiced without distinction."
attrs="{
'invisible': [
('invoicing_timesheet_enabled', '=', False),
]
}"
/>
<field name="invoicing_timesheet_enabled" invisible="1"/>
</xpath>
</field>
</record>
</odoo>