[REF] sale,sale_*: delivered qty API

Impacted modules: sale, sale_timesheet, sale_expense, sale_stock
and sale_mrp

This commit uniformizes the API of computing the delivered quantity on a
SO line for expense, manual, service and stock lines.

The delivered qty of so line can be computed differently. This is the
role of the 'qty_delivered_method' field:
- manual: the quantity is set manually, when creating or editing the
line
- analytic: handle in sale module, the quantity is the sum of the
unit_amount of analytic lines coming from expense or vendor bills
- timesheet: handle by sale_timesheet module, the quantity of a product
service with timesheet is compute as the sum of the hours of timesheet
lines linked to the SO line.
- stock_move: handle by sale_stock module, the delivered quantity comes
from the validated pickings of the SO line, for stockable or consummable
products.

When the computing method is 'manual' the value is stored in
'qty_deliverec_manual' field. This field is 'technical' and should not
really be used, as the real field to read/write is 'qty_delivered'.

To extend the mecanism of computing delivered quantity on SO line, a
module should provide its own method for 'qty_delivered_method' and extend
'_compute_qty_delivered' method to compute the effective quantity.
This commit is contained in:
jem-odoo
2018-01-29 17:16:27 +01:00
parent 5f660cdb28
commit 3bf8a62e0d
11 changed files with 214 additions and 179 deletions
+20 -35
View File
@@ -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})
+105 -67
View File
@@ -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
+1
View File
@@ -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
+7 -5
View File
@@ -247,7 +247,8 @@
<field name="layout_category_id" groups="sale.group_sale_layout"/>
<field name="invoice_status" invisible="1"/>
<field name="qty_to_invoice" invisible="1"/>
<field name="qty_delivered_updateable" invisible="1"/>
<field name="qty_delivered_manual" invisible="1"/>
<field name="qty_delivered_method" invisible="1"/>
<field name="price_subtotal" invisible="1"/>
<label for="product_uom_qty" string="Ordered Quantity"/>
<div>
@@ -259,7 +260,7 @@
</div>
<label for="qty_delivered" string="Delivered Quantity" attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
<div attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}">
<field name="qty_delivered" attrs="{'readonly': [('qty_delivered_updateable', '=', False)]}"/>
<field name="qty_delivered" attrs="{'readonly': [('qty_delivered_method', '!=', 'manual')]}"/>
</div>
<label for="qty_invoiced" string="Invoiced Quantity" attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
<div attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}">
@@ -308,7 +309,9 @@
context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}"
/>
<field name="qty_delivered"
attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])], 'readonly': [('qty_delivered_updateable', '=', False)]}"/>
attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])], 'readonly': [('qty_delivered_method', '!=', 'manual')]}"/>
<field name="qty_delivered_manual" invisible="1"/>
<field name="qty_delivered_method" invisible="1"/>
<field name="qty_invoiced"
attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
<field name="qty_to_invoice" invisible="1"/>
@@ -324,7 +327,6 @@
<field name="discount" groups="sale.group_discount_per_so_line"/>
<field name="price_subtotal" widget="monetary" groups="sale.group_show_price_subtotal"/>
<field name="price_total" widget="monetary" groups="sale.group_show_price_total"/>
<field name="qty_delivered_updateable" invisible="1"/>
<field name="state" invisible="1"/>
<field name="invoice_status" invisible="1"/>
<field name="customer_lead" invisible="1"/>
@@ -392,7 +394,7 @@
<group string="Reporting" name="technical" groups="base.group_no_one">
<field groups="base.group_no_one" name="origin"/>
</group>
<group name="utm_link" groups="base.group_no_one"/>
<group name="utm_link" groups="base.group_no_one"/>
</group>
</page>
</notebook>
@@ -76,12 +76,14 @@ class TestReInvoice(TestExpenseCommon, TestCommonSaleNoChart):
self.assertEquals(sale_order_line.qty_delivered, 0, "Exising SO line should not be impacted by reinvoicing product at cost")
sol_ordered = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_ordered_cost and sol != sale_order_line)
self.assertEquals(sol_ordered.qty_delivered_method, 'analytic', 'Delivered quantity of expense SO line should be computed by analytic amount')
self.assertTrue(sol_ordered, "A new line with ordered expense should have been created on expense report posting")
self.assertEquals(sol_ordered.price_unit, expense1.unit_amount, "The unit price of new SO line should be the one from the expense (at cost)")
self.assertEquals(sol_ordered.product_uom_qty, 0, "The ordered quantity of new SO line should be zero")
self.assertEquals(sol_ordered.qty_delivered, expense1.quantity, "The delivered quantity of new SO line should be the one from the expense")
sol_deliver = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_deliver_cost and sol != sale_order_line)
self.assertEquals(sol_deliver.qty_delivered_method, 'analytic', 'Delivered quantity of expense SO line should be computed by analytic amount')
self.assertTrue(sol_deliver, "A new line with delivered expense should have been created on expense report posting")
self.assertEquals(sol_deliver.price_unit, expense2.unit_amount, "The unit price of new SO line should be the one from the expense (at cost)")
self.assertEquals(sol_deliver.product_uom_qty, 0, "The ordered quantity of new SO line should be zero")
+13 -12
View File
@@ -9,19 +9,20 @@ class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
@api.multi
def _get_delivered_qty(self):
self.ensure_one()
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
# In the case of a kit, we need to check if all components are shipped. Since the BOM might
# have changed, we don't compute the quantities but verify the move state.
bom = self.env['mrp.bom']._bom_find(product=self.product_id)
if bom and bom.type == 'phantom':
bom_delivered = all([move.state == 'done' for move in self.move_ids])
if bom_delivered:
return self.product_uom_qty
else:
return 0.0
return super(SaleOrderLine, self)._get_delivered_qty()
for line in self:
if line.qty_delivered_method == 'stock_move' and line.product_id.expense_policy == 'no':
# In the case of a kit, we need to check if all components are shipped. Since the BOM might
# have changed, we don't compute the quantities but verify the move state.
bom = self.env['mrp.bom']._bom_find(product=line.product_id)
if bom and bom.type == 'phantom':
bom_delivered = all([move.state == 'done' for move in line.move_ids])
if bom_delivered:
line.qty_delivered = line.product_uom_qty
else:
line.qty_delivered = 0.0
@api.multi
def _get_bom_component_qty(self, bom):
+31 -25
View File
@@ -86,10 +86,40 @@ class SaleOrder(models.Model):
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
product_packaging = fields.Many2one('product.packaging', string='Package', default=False)
route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict')
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
@api.multi
@api.depends('product_id.type')
def _compute_qty_delivered_method(self):
""" Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])] """
super(SaleOrderLine, self)._compute_qty_delivered_method()
for line in self:
if line.product_id.type in ['consu', 'product']:
if line.product_id.expense_policy == 'no':
line.qty_delivered_method = 'stock_move'
else:
line.qty_delivered_method = 'analytic'
@api.multi
@api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
for line in self: # TODO: maybe one day, this should be done in SQL for performance sake
if line.qty_delivered_method == 'stock_move':
qty = 0.0
for move in line.move_ids.filtered(lambda r: r.state == 'done' and not r.scrapped):
if move.location_dest_id.usage == "customer":
if not move.origin_returned_move_id:
qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
elif move.location_dest_id.usage != "customer" and move.to_refund:
qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
line.qty_delivered = qty
@api.model
def create(self, values):
line = super(SaleOrderLine, self).create(values)
@@ -108,7 +138,7 @@ class SaleOrderLine(models.Model):
if lines:
lines._action_launch_procurement_rule()
return res
@api.depends('order_id.state')
def _compute_invoice_status(self):
@@ -134,17 +164,6 @@ class SaleOrderLine(models.Model):
else:
line.product_updatable = False
@api.multi
@api.depends('product_id')
def _compute_qty_delivered_updateable(self):
# prefetch field before filtering
self.mapped('product_id')
# on consumable or stockable products, qty_delivered_updateable defaults
# to False; on other lines use the original computation
lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product'))
lines = lines.with_prefetch(self._prefetch)
super(SaleOrderLine, lines)._compute_qty_delivered_updateable()
@api.onchange('product_id')
def _onchange_product_id_set_customer_lead(self):
self.customer_lead = self.product_id.sale_delay
@@ -271,19 +290,6 @@ class SaleOrderLine(models.Model):
raise UserError('\n'.join(errors))
return True
@api.multi
def _get_delivered_qty(self):
self.ensure_one()
super(SaleOrderLine, self)._get_delivered_qty()
qty = 0.0
for move in self.move_ids.filtered(lambda r: r.state == 'done' and not r.scrapped):
if move.location_dest_id.usage == "customer":
if not move.origin_returned_move_id:
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
elif move.location_dest_id.usage != "customer" and move.to_refund:
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
return qty
@api.multi
def _check_package(self):
default_uom = self.product_id.uom_id
-17
View File
@@ -26,23 +26,6 @@ class StockMove(models.Model):
keys_sorted.append(move.sale_line_id.id)
return keys_sorted
def _action_done(self):
result = super(StockMove, self)._action_done()
for line in self.mapped('sale_line_id'):
line.qty_delivered = line._get_delivered_qty()
return result
@api.multi
def write(self, vals):
res = super(StockMove, self).write(vals)
if 'product_uom_qty' in vals:
for move in self:
if move.state == 'done':
sale_order_lines = self.filtered(lambda move: move.sale_line_id).mapped('sale_line_id')
for line in sale_order_lines:
line.qty_delivered = line._get_delivered_qty()
return res
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
+5
View File
@@ -3,11 +3,16 @@
from odoo.exceptions import UserError
from odoo import api, fields, models, _
from odoo.osv import expression
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
def _default_sale_line_domain(self):
domain = super(AccountAnalyticLine, self)._default_sale_line_domain()
return expression.OR([domain, [('qty_delivered_method', '=', 'timesheet')]])
timesheet_invoice_type = fields.Selection([
('billable_time', 'Billable Time'),
('billable_fixed', 'Billable Fixed'),
+29 -16
View File
@@ -136,9 +136,38 @@ class SaleOrder(models.Model):
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item")
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
@api.multi
@api.depends('product_id.type', 'product_id.service_type')
def _compute_qty_delivered_method(self):
""" Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
super(SaleOrderLine, self)._compute_qty_delivered_method()
for line in self:
if line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
if line.product_id.expense_policy == 'no':
line.qty_delivered_method = 'timesheet'
else:
line.qty_delivered_method = 'analytic'
@api.multi
@api.depends('analytic_line_ids.project_id')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
mapping = lines_by_timesheet._get_delivered_quantity_by_analytic(domain)
for line in lines_by_timesheet:
line.qty_delivered = mapping.get(line.id, 0.0)
@api.multi
def _timesheet_compute_delivered_quantity_domain(self):
""" Hook for validated timesheet in addionnal module """
return [('project_id', '!=', False)]
@api.multi
@api.depends('product_id.type')
def _compute_is_service(self):
@@ -162,22 +191,6 @@ class SaleOrderLine(models.Model):
line._timesheet_service_generation()
return line
###########################################
### Analytic : auto recompute delivered quantity
###########################################
def _timesheet_compute_delivered_quantity_domain(self):
# TODO JEM: avoid increment delivered for all AAL or just timesheet ?
# see nim commit https://github.com/odoo/odoo/commit/21fbb9776a5fbd1838b189f1f7cf8c5d40663e14
so_line_ids = self.filtered(lambda sol: sol.product_id.service_type != 'manual').ids
return ['&', ('so_line', 'in', so_line_ids), ('project_id', '!=', False)]
@api.multi
def _analytic_compute_delivered_quantity_domain(self):
domain = super(SaleOrderLine, self)._analytic_compute_delivered_quantity_domain()
timesheet_domain = self._timesheet_compute_delivered_quantity_domain()
return expression.OR([domain, timesheet_domain])
###########################################
## Service : Project and task generation
###########################################
@@ -20,8 +20,7 @@ class TestSaleService(CommonTest):
'product_uom_qty': 50,
'product_uom': self.product_delivery_timesheet2.uom_id.id,
'price_unit': self.product_delivery_timesheet2.list_price
}),
],
})],
'pricelist_id': self.pricelist_usd.id,
}
sale_order = self.env['sale.order'].create(sale_order_vals)