diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index 73b9f95d93a..b0a0a2b09fe 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -8,47 +8,26 @@ from odoo.exceptions import UserError class AccountAnalyticLine(models.Model): _inherit = "account.analytic.line" - so_line = fields.Many2one('sale.order.line', string='Sales Order Line') + def _default_sale_line_domain(self): + """ This is only used for delivered quantity of SO line based on analytic line, and timesheet + (see sale_timesheet). This can be override to allow further customization. + """ + return [('qty_delivered_method', '=', 'analytic')] + + so_line = fields.Many2one('sale.order.line', string='Sales Order Line', domain=lambda self: self._default_sale_line_domain()) @api.model def create(self, values): result = super(AccountAnalyticLine, self).create(values) - result._sale_postprocess(values) + if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no': # allow to force a False value for so_line + result.sudo()._sale_determine_order_line() return result @api.multi def write(self, values): - # get current so lines for which update qty wil be required - sale_order_lines = self.env['sale.order.line'] - if 'so_line' in values: - sale_order_lines = self.sudo().mapped('so_line') result = super(AccountAnalyticLine, self).write(values) - self._sale_postprocess(values, additional_so_lines=sale_order_lines) - return result - - @api.multi - def unlink(self): - sale_order_lines = self.sudo().mapped('so_line') - res = super(AccountAnalyticLine, self).unlink() - sale_order_lines.with_context(sale_analytic_force_recompute=True)._analytic_compute_delivered_quantity() - return res - - @api.model - def _sale_get_fields_delivered_qty(self): - """ Returns a list with the field impacting the delivered quantity on SO line. """ - return ['so_line', 'unit_amount', 'product_uom_id'] - - @api.multi - def _sale_postprocess(self, values, additional_so_lines=None): if 'so_line' not in values: # allow to force a False value for so_line - self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no').with_context(sale_analytic_norecompute=True)._sale_determine_order_line() - - if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()): - if not self._context.get('sale_analytic_norecompute'): - so_lines = self.sudo().filtered(lambda aal: aal.so_line).mapped('so_line') - if additional_so_lines: - so_lines |= additional_so_lines - so_lines.sudo()._analytic_compute_delivered_quantity() + self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no')._sale_determine_order_line() # NOTE JEM: thoses method are used in vendor bills to reinvoice at cost (see test `test_cost_invoicing`) # some cleaning are still necessary @@ -119,6 +98,7 @@ class AccountAnalyticLine(models.Model): # determine SO : first SO open linked to AA sale_order_map = self._sale_determine_order() # determine so line + value_to_write = {} for analytic_line in self: sale_order = sale_order_map.get(analytic_line.id) if not sale_order: @@ -133,13 +113,12 @@ class AccountAnalyticLine(models.Model): so_line = self.env['sale.order.line'].search([ ('order_id', '=', sale_order.id), ('price_unit', '=', price), - ('product_id', '=', self.product_id.id) + ('product_id', '=', self.product_id.id), + ('qty_delivered_method', '=', 'analytic'), ], limit=1) if not so_line: # generate a new SO line - if sale_order.state != 'sale': - raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name) so_line_values = analytic_line._sale_prepare_sale_order_line_values(sale_order, price) so_line = self.env['sale.order.line'].create(so_line_values) so_line._compute_tax_id() @@ -147,4 +126,10 @@ class AccountAnalyticLine(models.Model): so_line.write({'qty_delivered': so_line.qty_delivered + analytic_line.unit_amount}) if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line) - analytic_line.with_context(sale_analytic_norecompute=True).write({'so_line': so_line.id}) + value_to_write.setdefault(so_line.id, self.env['account.analytic.line']) + value_to_write[so_line.id] |= analytic_line + + # write so line on (maybe) multiple AAL to trigger only one read_group per SO line + for so_line_id, analytic_lines in value_to_write.items(): + if analytic_lines: + analytic_lines.write({'so_line': so_line_id}) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index d1e6324690f..9fbd43b081f 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -716,11 +716,6 @@ class SaleOrderLine(models.Model): else: line.product_updatable = True - @api.depends('product_id.invoice_policy', 'order_id.state') - def _compute_qty_delivered_updateable(self): - for line in self: - line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.service_type == 'manual') and (line.product_id.expense_policy == 'no') - @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state') def _get_to_invoice_qty(self): """ @@ -873,8 +868,17 @@ class SaleOrderLine(models.Model): # Non-stored related field to allow portal user to see the image of the product he has ordered product_image = fields.Binary('Product Image', related="product_id.image", store=False) - qty_delivered_updateable = fields.Boolean(compute='_compute_qty_delivered_updateable', string='Can Edit Delivered', readonly=True, default=True) - qty_delivered = fields.Float(string='Delivered', copy=False, digits=dp.get_precision('Product Unit of Measure'), default=0.0) + qty_delivered_method = fields.Selection([ + ('manual', 'Manual'), + ('analytic', 'Analytic From Expenses') + ], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True, + help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n" + " - Manual: the quantity is set manually on the line\n" + " - Analytic From expenses: the quantity is the quantity sum from posted expenses\n" + " - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n" + " - Stock Moves: the quantity comes from confirmed pickings\n") + qty_delivered = fields.Float('Delivered', copy=False, compute='_compute_qty_delivered', inverse='_inverse_qty_delivered', compute_sudo=True, store=True, digits=dp.get_precision('Product Unit of Measure'), default=0.0) + qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits=dp.get_precision('Product Unit of Measure'), default=0.0) qty_to_invoice = fields.Float( compute='_get_to_invoice_qty', string='To Invoice', store=True, readonly=True, digits=dp.get_precision('Product Unit of Measure')) @@ -887,6 +891,7 @@ class SaleOrderLine(models.Model): company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True) order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') + analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines") is_downpayment = fields.Boolean( string="Is a down payment", help="Down payments are made when creating invoices from a sales order." " They are not copied when duplicating a sales order.") @@ -906,6 +911,99 @@ class SaleOrderLine(models.Model): layout_category_sequence = fields.Integer(string='Layout Sequence') # TODO: remove layout_category_sequence in master or make it work properly + @api.multi + @api.depends('state', 'product_id.expense_policy') + def _compute_qty_delivered_method(self): + """ Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')] + - consu + expense_policy : analytic (sum of analytic unit_amount) + - consu + no expense_policy : manual (set manually on SOL) + - service (+ service_type='manual', the only available option) : manual + + This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type, + and sale_timesheet implements the behavior of 'service' + service_type=timesheet. + """ + for line in self: + if line.product_id.type == 'consu': + if line.product_id.expense_policy == 'no': + line.qty_delivered_method = 'manual' + else: + line.qty_delivered_method = 'analytic' + else: # NOTE: line.product_id.type == 'service' and line.product_id.service_type == 'manual': + if line.product_id.expense_policy == 'no': + line.qty_delivered_method = 'manual' # ultimate fallback + else: + line.qty_delivered_method = 'analytic' + + @api.multi + @api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id') + def _compute_qty_delivered(self): + """ This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka + expense/vendor bills (sum of unit_amount of AAL), and manual case. + This method should be overriden to provide other way to automatically compute delivered qty. Overrides should + take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining + records. + """ + # compute for analytic lines + lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic') + mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)]) + for so_line in lines_by_analytic: + so_line.qty_delivered = mapping.get(so_line.id, 0.0) + # compute for manual lines + for line in self: + if line.qty_delivered_method == 'manual': + line.qty_delivered = line.qty_delivered_manual or 0.0 + + @api.multi + def _get_delivered_quantity_by_analytic(self, additional_domain): + """ Compute and write the delivered quantity of current SO lines, based on their related + analytic lines. + :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...) + """ + result = {} + # avoid recomputation if no SO lines concerned + if not self: + return result + + # group anaytic lines by product uom and so line + domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain]) + data = self.env['account.analytic.line'].read_group( + domain, + ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False + ) + + # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines + # browse so lines and product uoms here to make them share the same prefetch + lines_map = {line.id: line for line in self} + product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']] + product_uom_map = {uom.id: uom for uom in self.env['product.uom'].browse(product_uom_ids)} + for item in data: + if not item['product_uom_id']: + continue + so_line_id = item['so_line'][0] + so_line = lines_map[so_line_id] + result.setdefault(so_line_id, 0.0) + uom = product_uom_map.get(item['product_uom_id'][0]) + if so_line.product_uom.category_id == uom.category_id: + qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom) + else: + qty = item['unit_amount'] + result[so_line_id] += qty + + return result + + @api.multi + @api.onchange('qty_delivered') + def _inverse_qty_delivered(self): + """ When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only), + then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the + delivered qty is automatically compute by other mecanisms. + """ + for line in self: + if line.qty_delivered_method == 'manual': + line.qty_delivered_manual = line.qty_delivered + else: + line.qty_delivered_manual = 0.0 + @api.multi def _prepare_invoice_line(self, qty): """ @@ -1071,15 +1169,6 @@ class SaleOrderLine(models.Model): raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.')) return super(SaleOrderLine, self).unlink() - @api.multi - def _get_delivered_qty(self): - ''' - Intended to be overridden in sale_stock and sale_mrp - :return: the quantity delivered - :rtype: float - ''' - return 0.0 - def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): """Retrieve the price before applying the pricelist :param obj product: object of current product record @@ -1153,54 +1242,3 @@ class SaleOrderLine(models.Model): discount = (new_list_price - price) / new_list_price * 100 if discount > 0: self.discount = discount - - ########################### - # Analytic Methods - ########################### - - @api.multi - def _analytic_compute_delivered_quantity_domain(self): - """ Return the domain of the analytic lines to use to recompute the delivered quantity - on SO lines. This method is a hook: since analytic line are used for timesheet, - expense, ... each use case should provide its part of the domain. - """ - return [('so_line', 'in', self.ids), ('amount', '<=', 0.0)] - - @api.multi - def _analytic_compute_delivered_quantity(self): - """ Compute and write the delivered quantity of current SO lines, based on their related - analytic lines. - """ - # avoid recomputation if no SO lines concerned - if not self: - return False - - # group anaytic lines by product uom and so line - domain = self._analytic_compute_delivered_quantity_domain() - data = self.env['account.analytic.line'].read_group( - domain, - ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False - ) - # Force recompute for the "unlink last line" case: if remove the last AAL link to the SO, the read_group - # will give no value for the qty of the SOL, so we need to reset it to 0.0 - value_to_write = {} - if self._context.get('sale_analytic_force_recompute'): - value_to_write = dict.fromkeys([sol for sol in self], 0.0) - # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines - for item in data: - if not item['product_uom_id']: - continue - so_line = self.browse(item['so_line'][0]) - value_to_write.setdefault(so_line, 0.0) - uom = self.env['product.uom'].browse(item['product_uom_id'][0]) - if so_line.product_uom.category_id == uom.category_id: - qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom) - else: - qty = item['unit_amount'] - value_to_write[so_line] += qty - - # write the delivered quantity - for so_line, qty in value_to_write.items(): - so_line.write({'qty_delivered': qty}) - - return True diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 87271c0324f..a68c992438b 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import odoo from odoo.exceptions import UserError, AccessError from .test_sale_common import TestCommonSaleNoChart diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index e8f9a70b3cf..a1ca58b6eba 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -247,7 +247,8 @@ - + +