[MERGE][IMP] sale_*: split files by models & cleanups
-- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr closes odoo/odoo#85001 Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
This commit is contained in:
@@ -13,7 +13,11 @@ This gives the profitability by calculating the difference between the Unit
|
||||
Price and Cost Price.
|
||||
""",
|
||||
'depends':['sale_management'],
|
||||
'demo':['data/sale_margin_demo.xml'],
|
||||
'data':['security/ir.model.access.csv','views/sale_margin_view.xml'],
|
||||
'demo':[
|
||||
'data/sale_margin_demo.xml',
|
||||
],
|
||||
'data':[
|
||||
'views/sale_order_views.xml',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="sale.sale_order_line_1" model="sale.order.line">
|
||||
<field name="purchase_price">2870.00</field>
|
||||
@@ -34,5 +33,4 @@
|
||||
<field name="purchase_price">13.00</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
|
||||
@@ -1,63 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
margin = fields.Float(
|
||||
"Margin", compute='_compute_margin',
|
||||
digits='Product Price', store=True, groups="base.group_user", precompute=True)
|
||||
margin_percent = fields.Float(
|
||||
"Margin (%)", compute='_compute_margin', store=True, groups="base.group_user", precompute=True)
|
||||
purchase_price = fields.Float(
|
||||
string='Cost', compute="_compute_purchase_price",
|
||||
digits='Product Price', store=True, readonly=False, precompute=True,
|
||||
groups="base.group_user")
|
||||
|
||||
@api.depends('product_id', 'company_id', 'currency_id', 'product_uom')
|
||||
def _compute_purchase_price(self):
|
||||
for line in self:
|
||||
if not line.product_id:
|
||||
line.purchase_price = 0.0
|
||||
continue
|
||||
line = line.with_company(line.company_id)
|
||||
product_cost = line.product_id.standard_price
|
||||
line.purchase_price = line._convert_price(product_cost, line.product_id.uom_id)
|
||||
|
||||
@api.depends('price_subtotal', 'product_uom_qty', 'purchase_price')
|
||||
def _compute_margin(self):
|
||||
for line in self:
|
||||
line.margin = line.price_subtotal - (line.purchase_price * line.product_uom_qty)
|
||||
line.margin_percent = line.price_subtotal and line.margin/line.price_subtotal
|
||||
|
||||
def _convert_price(self, product_cost, from_uom):
|
||||
self.ensure_one()
|
||||
if not product_cost:
|
||||
# If the standard_price is 0
|
||||
# Avoid unnecessary computations
|
||||
# and currency conversions
|
||||
if not self.purchase_price:
|
||||
return product_cost
|
||||
from_currency = self.product_id.cost_currency_id
|
||||
to_cur = self.currency_id or self.order_id.currency_id
|
||||
to_uom = self.product_uom
|
||||
if to_uom and to_uom != from_uom:
|
||||
product_cost = from_uom._compute_price(
|
||||
product_cost,
|
||||
to_uom,
|
||||
)
|
||||
return from_currency._convert(
|
||||
from_amount=product_cost,
|
||||
to_currency=to_cur,
|
||||
company=self.company_id or self.env.company,
|
||||
date=self.order_id.date_order or fields.Date.today(),
|
||||
round=False,
|
||||
) if to_cur and product_cost else product_cost
|
||||
# The pricelist may not have been set, therefore no conversion
|
||||
# is needed because we don't know the target currency..
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
margin = fields.Float(
|
||||
"Margin", compute='_compute_margin',
|
||||
digits='Product Price', store=True, groups="base.group_user", precompute=True)
|
||||
margin_percent = fields.Float(
|
||||
"Margin (%)", compute='_compute_margin', store=True, groups="base.group_user", precompute=True)
|
||||
purchase_price = fields.Float(
|
||||
string='Cost', compute="_compute_purchase_price",
|
||||
digits='Product Price', store=True, readonly=False, precompute=True,
|
||||
groups="base.group_user")
|
||||
|
||||
@api.depends('product_id', 'company_id', 'currency_id', 'product_uom')
|
||||
def _compute_purchase_price(self):
|
||||
for line in self:
|
||||
if not line.product_id:
|
||||
line.purchase_price = 0.0
|
||||
continue
|
||||
line = line.with_company(line.company_id)
|
||||
product_cost = line.product_id.standard_price
|
||||
line.purchase_price = line._convert_price(product_cost, line.product_id.uom_id)
|
||||
|
||||
@api.depends('price_subtotal', 'product_uom_qty', 'purchase_price')
|
||||
def _compute_margin(self):
|
||||
for line in self:
|
||||
line.margin = line.price_subtotal - (line.purchase_price * line.product_uom_qty)
|
||||
line.margin_percent = line.price_subtotal and line.margin/line.price_subtotal
|
||||
|
||||
def _convert_price(self, product_cost, from_uom):
|
||||
self.ensure_one()
|
||||
if not product_cost:
|
||||
# If the standard_price is 0
|
||||
# Avoid unnecessary computations
|
||||
# and currency conversions
|
||||
if not self.purchase_price:
|
||||
return product_cost
|
||||
from_currency = self.product_id.cost_currency_id
|
||||
to_cur = self.currency_id or self.order_id.currency_id
|
||||
to_uom = self.product_uom
|
||||
if to_uom and to_uom != from_uom:
|
||||
product_cost = from_uom._compute_price(
|
||||
product_cost,
|
||||
to_uom,
|
||||
)
|
||||
return from_currency._convert(
|
||||
from_amount=product_cost,
|
||||
to_currency=to_cur,
|
||||
company=self.company_id or self.env.company,
|
||||
date=self.order_id.date_order or fields.Date.today(),
|
||||
round=False,
|
||||
) if to_cur and product_cost else product_cost
|
||||
# The pricelist may not have been set, therefore no conversion
|
||||
# is needed because we don't know the target currency..
|
||||
@@ -1 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
@@ -7,20 +7,21 @@ from datetime import datetime
|
||||
|
||||
class TestSaleMargin(common.TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestSaleMargin, self).setUp()
|
||||
self.SaleOrder = self.env['sale.order']
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.SaleOrder = cls.env['sale.order']
|
||||
|
||||
self.product_uom_id = self.ref('uom.product_uom_unit')
|
||||
self.product = self.env['product.product'].create({'name': 'Individual Workplace'})
|
||||
self.product_id = self.product.id
|
||||
self.partner_id = self.env['res.partner'].create({'name': 'A test partner'}).id
|
||||
self.partner_invoice_address_id = self.env['res.partner'].create({
|
||||
cls.product_uom_id = cls.env.ref('uom.product_uom_unit').id
|
||||
cls.product = cls.env['product.product'].create({'name': 'Individual Workplace'})
|
||||
cls.product_id = cls.product.id
|
||||
cls.partner_id = cls.env['res.partner'].create({'name': 'A test partner'}).id
|
||||
cls.partner_invoice_address_id = cls.env['res.partner'].create({
|
||||
'name': 'A test partner address',
|
||||
'parent_id': self.partner_id,
|
||||
'parent_id': cls.partner_id,
|
||||
}).id
|
||||
self.pricelist_id = self.ref('product.list0')
|
||||
self.pricelist = self.env.ref('product.list0')
|
||||
cls.pricelist = cls.env.ref('product.list0')
|
||||
cls.pricelist_id = cls.pricelist.id
|
||||
|
||||
def test_sale_margin(self):
|
||||
""" Test the sale_margin module in Odoo. """
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale
|
||||
from . import account_move
|
||||
from . import mrp_production
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import stock_move
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
mrp_production_count = fields.Integer(
|
||||
"Count of MO generated",
|
||||
compute='_compute_mrp_production_ids',
|
||||
groups='mrp.group_mrp_user')
|
||||
mrp_production_ids = fields.Many2many(
|
||||
'mrp.production',
|
||||
compute='_compute_mrp_production_ids',
|
||||
string='Manufacturing orders associated with this sales order.',
|
||||
groups='mrp.group_mrp_user')
|
||||
|
||||
@api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids')
|
||||
def _compute_mrp_production_ids(self):
|
||||
data = self.env['procurement.group'].read_group([('sale_id', 'in', self.ids)], ['ids:array_agg(id)'], ['sale_id'])
|
||||
mrp_productions = dict()
|
||||
for item in data:
|
||||
procurement_groups = self.env['procurement.group'].browse(item['ids'])
|
||||
mrp_productions[item['sale_id'][0]] = procurement_groups.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids | procurement_groups.mrp_production_ids
|
||||
for sale in self:
|
||||
mrp_production_ids = mrp_productions.get(sale.id, self.env['mrp.production'])
|
||||
sale.mrp_production_count = len(mrp_production_ids)
|
||||
sale.mrp_production_ids = mrp_production_ids
|
||||
|
||||
def action_view_mrp_production(self):
|
||||
self.ensure_one()
|
||||
action = {
|
||||
'res_model': 'mrp.production',
|
||||
'type': 'ir.actions.act_window',
|
||||
}
|
||||
if len(self.mrp_production_ids) == 1:
|
||||
action.update({
|
||||
'view_mode': 'form',
|
||||
'res_id': self.mrp_production_ids.id,
|
||||
})
|
||||
else:
|
||||
action.update({
|
||||
'name': _("Manufacturing Orders Generated by %s", self.name),
|
||||
'domain': [('id', 'in', self.mrp_production_ids.ids)],
|
||||
'view_mode': 'tree,form',
|
||||
})
|
||||
return action
|
||||
|
||||
def _get_additional_order_page_values(self):
|
||||
rendering_context_values = super()._get_additional_order_page_values()
|
||||
rendering_context_values['mrp_production_ids'] = self.mrp_production_ids
|
||||
return rendering_context_values
|
||||
@@ -1,58 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
mrp_production_count = fields.Integer(
|
||||
"Count of MO generated",
|
||||
compute='_compute_mrp_production_ids',
|
||||
groups='mrp.group_mrp_user')
|
||||
mrp_production_ids = fields.Many2many(
|
||||
'mrp.production',
|
||||
compute='_compute_mrp_production_ids',
|
||||
string='Manufacturing orders associated with this sales order.',
|
||||
groups='mrp.group_mrp_user')
|
||||
|
||||
@api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids')
|
||||
def _compute_mrp_production_ids(self):
|
||||
data = self.env['procurement.group'].read_group([('sale_id', 'in', self.ids)], ['ids:array_agg(id)'], ['sale_id'])
|
||||
mrp_productions = dict()
|
||||
for item in data:
|
||||
procurement_groups = self.env['procurement.group'].browse(item['ids'])
|
||||
mrp_productions[item['sale_id'][0]] = procurement_groups.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids | procurement_groups.mrp_production_ids
|
||||
for sale in self:
|
||||
mrp_production_ids = mrp_productions.get(sale.id, self.env['mrp.production'])
|
||||
sale.mrp_production_count = len(mrp_production_ids)
|
||||
sale.mrp_production_ids = mrp_production_ids
|
||||
|
||||
def action_view_mrp_production(self):
|
||||
self.ensure_one()
|
||||
action = {
|
||||
'res_model': 'mrp.production',
|
||||
'type': 'ir.actions.act_window',
|
||||
}
|
||||
if len(self.mrp_production_ids) == 1:
|
||||
action.update({
|
||||
'view_mode': 'form',
|
||||
'res_id': self.mrp_production_ids.id,
|
||||
})
|
||||
else:
|
||||
action.update({
|
||||
'name': _("Manufacturing Orders Generated by %s", self.name),
|
||||
'domain': [('id', 'in', self.mrp_production_ids.ids)],
|
||||
'view_mode': 'tree,form',
|
||||
})
|
||||
return action
|
||||
|
||||
def _get_additional_order_page_values(self):
|
||||
rendering_context_values = super()._get_additional_order_page_values()
|
||||
rendering_context_values['mrp_production_ids'] = self.mrp_production_ids
|
||||
return rendering_context_values
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@@ -16,8 +16,11 @@ It also enables the "optional products" feature.
|
||||
'depends': ['sale'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
'views/product_template_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/templates.xml',
|
||||
'views/sale_views.xml',
|
||||
|
||||
'wizard/sale_product_configurator_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="product_product_1_product_template" model="product.template">
|
||||
<field name="name">Chair floor protection</field>
|
||||
<field name="categ_id" ref="product.product_category_5"/>
|
||||
<field name="list_price">12.0</field>
|
||||
<field name="weight">0.01</field>
|
||||
<field name="uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_unit"/>
|
||||
<field name="description_sale">Office chairs can harm your floor: protect it.</field>
|
||||
<field name="image_1920" type="base64" file="sale/static/img/floor_protection-image.jpg"/>
|
||||
</record>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="product_product_1_product_template" model="product.template">
|
||||
<field name="name">Chair floor protection</field>
|
||||
<field name="categ_id" ref="product.product_category_5"/>
|
||||
<field name="list_price">12.0</field>
|
||||
<field name="weight">0.01</field>
|
||||
<field name="uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_unit"/>
|
||||
<field name="description_sale">Office chairs can harm your floor: protect it.</field>
|
||||
<field name="image_1920" type="base64" file="sale/static/img/floor_protection-image.jpg"/>
|
||||
</record>
|
||||
|
||||
<record id="product.product_product_4_product_template" model="product.template">
|
||||
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
|
||||
</record>
|
||||
<record id="product.product_product_11_product_template" model="product.template">
|
||||
<field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/>
|
||||
</record>
|
||||
<record id="product.product_product_13_product_template" model="product.template">
|
||||
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
|
||||
</record>
|
||||
|
||||
<record id="product.product_product_4_product_template" model="product.template">
|
||||
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
|
||||
</record>
|
||||
<record id="product.product_product_11_product_template" model="product.template">
|
||||
<field name="optional_product_ids" eval="[(6,0,[ref('product_product_1_product_template')])]"/>
|
||||
</record>
|
||||
<record id="product.product_product_13_product_template" model="product.template">
|
||||
<field name="optional_product_ids" eval="[(6,0,[ref('product.product_product_11_product_template')])]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import product_template
|
||||
from . import sale_order_line
|
||||
from . import product
|
||||
|
||||
+10
-7
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, api
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
@@ -21,9 +21,11 @@ class ProductTemplate(models.Model):
|
||||
- It has any attribute line with at least 2 attribute values configured
|
||||
- It has at least one custom attribute value """
|
||||
for product in self:
|
||||
product.has_configurable_attributes = any(attribute.create_variant == 'dynamic' for attribute in product.mapped('attribute_line_ids.attribute_id')) \
|
||||
or any(len(attribute_line_id.value_ids) >= 2 for attribute_line_id in product.attribute_line_ids) \
|
||||
or any(attribute_value.is_custom for attribute_value in product.mapped('attribute_line_ids.value_ids'))
|
||||
product.has_configurable_attributes = (
|
||||
any(attribute.create_variant == 'dynamic' for attribute in product.attribute_line_ids.attribute_id)
|
||||
or any(len(attribute_line_id.value_ids) >= 2 for attribute_line_id in product.attribute_line_ids)
|
||||
or any(attribute_value.is_custom for attribute_value in product.attribute_line_ids.value_ids)
|
||||
)
|
||||
|
||||
def get_single_product_variant(self):
|
||||
""" Method used by the product configurator to check if the product is configurable or not.
|
||||
@@ -31,12 +33,13 @@ class ProductTemplate(models.Model):
|
||||
We need to open the product configurator if the product:
|
||||
- is configurable (see has_configurable_attributes)
|
||||
- has optional products """
|
||||
self.ensure_one()
|
||||
res = super(ProductTemplate, self).get_single_product_variant()
|
||||
res = super().get_single_product_variant()
|
||||
if res.get('product_id', False):
|
||||
has_optional_products = False
|
||||
for optional_product in self.product_variant_id.optional_product_ids:
|
||||
if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(self.product_variant_id.product_template_attribute_value_ids):
|
||||
if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(
|
||||
self.product_variant_id.product_template_attribute_value_ids
|
||||
):
|
||||
has_optional_products = True
|
||||
break
|
||||
res.update({'has_optional_products': has_optional_products})
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="product_template_view_form" model="ir.ui.view">
|
||||
<field name="name">product.template.form.inherit.sale.product.configurator</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<group name="upsell" position="inside">
|
||||
<field name="optional_product_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'color_field': 'color'}"
|
||||
domain="[('id', '!=', active_id), '|', ('company_id', '=', company_id), ('company_id', '=', False)]"
|
||||
placeholder="Recommend when 'Adding to Cart' or quotation"
|
||||
groups="product.group_product_variant" />
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
+1
-15
@@ -1,20 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="product_template_view_form" model="ir.ui.view">
|
||||
<field name="name">product.template.form.inherit.sale.product.configurator</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<group name="upsell" position="inside">
|
||||
<field name="optional_product_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'color_field': 'color'}"
|
||||
domain="[('id', '!=', active_id), '|', ('company_id', '=', company_id), ('company_id', '=', False)]"
|
||||
placeholder="Recommend when 'Adding to Cart' or quotation"
|
||||
groups="product.group_product_variant" />
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_order_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.inherit.sale.product.configurator</field>
|
||||
@@ -41,4 +26,5 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -12,7 +12,7 @@
|
||||
'depends': ['sale', 'product_matrix', 'sale_product_configurator'],
|
||||
'data': [
|
||||
'views/product_template_views.xml',
|
||||
'views/sale_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'report/sale_report_templates.xml',
|
||||
],
|
||||
'demo': [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_order_form_with_variant_grid" model="ir.ui.view">
|
||||
<field name="name">sale.order.line.variant.grid</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<!-- Technical non stored fields for Order Grid Entry -->
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//notebook//group[@name='sales_person']" position="inside">
|
||||
<field name="report_grids" groups="base.group_no_one"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_order_form_with_variant_grid" model="ir.ui.view">
|
||||
<field name="name">sale.order.line.variant.grid</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<!-- Technical non stored fields for Order Grid Entry -->
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//notebook//group[@name='sales_person']" position="inside">
|
||||
<field name="report_grids" groups="base.group_no_one"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,12 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from operator import itemgetter
|
||||
|
||||
from odoo import _
|
||||
from odoo.http import request
|
||||
from odoo.tools import groupby as groupbyelem
|
||||
|
||||
from odoo.osv.expression import OR
|
||||
|
||||
from odoo.addons.project.controllers.portal import ProjectCustomerPortal
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
from . import product
|
||||
from . import project
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
from odoo.tools.sql import column_exists, create_column
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
@@ -114,283 +112,3 @@ class SaleOrder(models.Model):
|
||||
if 'state' in values and values['state'] == 'cancel':
|
||||
self.project_id.sale_line_id = False
|
||||
return super(SaleOrder, self).write(values)
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
project_id = fields.Many2one(
|
||||
'project.project', 'Generated Project',
|
||||
index=True, copy=False, help="Project generated by the sales order item")
|
||||
task_id = fields.Many2one(
|
||||
'project.task', 'Generated Task',
|
||||
index=True, copy=False, help="Task generated by the sales order item")
|
||||
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_is_service(self):
|
||||
for so_line in self:
|
||||
so_line.is_service = so_line.product_id.type == 'service'
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_product_updatable(self):
|
||||
for line in self:
|
||||
if line.product_id.type == 'service' and line.state == 'sale':
|
||||
line.product_updatable = False
|
||||
else:
|
||||
super(SaleOrderLine, line)._compute_product_updatable()
|
||||
|
||||
def _auto_init(self):
|
||||
"""
|
||||
Create column to stop ORM from computing it himself (too slow)
|
||||
"""
|
||||
if not column_exists(self.env.cr, 'sale_order_line', 'is_service'):
|
||||
create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool')
|
||||
self.env.cr.execute("""
|
||||
UPDATE sale_order_line line
|
||||
SET is_service = (pt.type = 'service')
|
||||
FROM product_product pp
|
||||
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE pp.id = line.product_id
|
||||
""")
|
||||
return super()._auto_init()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super().create(vals_list)
|
||||
# Do not generate task/project when expense SO line, but allow
|
||||
# generate task with hours=0.
|
||||
for line in lines:
|
||||
if line.state == 'sale' and not line.is_expense:
|
||||
line.sudo()._timesheet_service_generation()
|
||||
# if the SO line created a task, post a message on the order
|
||||
if line.task_id:
|
||||
msg_body = _("Task Created (%s): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (line.product_id.name, line.task_id.id, line.task_id.name)
|
||||
line.order_id.message_post(body=msg_body)
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
result = super().write(values)
|
||||
# changing the ordered quantity should change the planned hours on the
|
||||
# task, whatever the SO state. It will be blocked by the super in case
|
||||
# of a locked sale order.
|
||||
if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
|
||||
for line in self:
|
||||
if line.task_id and line.product_id.type == 'service':
|
||||
planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
|
||||
line.task_id.write({'planned_hours': planned_hours})
|
||||
return result
|
||||
|
||||
###########################################
|
||||
# Service : Project and task generation
|
||||
###########################################
|
||||
|
||||
def _convert_qty_company_hours(self, dest_company):
|
||||
return self.product_uom_qty
|
||||
|
||||
def _timesheet_create_project_prepare_values(self):
|
||||
"""Generate project values"""
|
||||
account = self.order_id.analytic_account_id
|
||||
if not account:
|
||||
service_products = self.order_id.order_line.product_id.filtered(lambda p: p.type == 'service' and p.default_code)
|
||||
default_code = service_products.default_code if len(service_products) == 1 else None
|
||||
self.order_id._create_analytic_account(prefix=default_code)
|
||||
account = self.order_id.analytic_account_id
|
||||
|
||||
# create the project or duplicate one
|
||||
return {
|
||||
'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
|
||||
'analytic_account_id': account.id,
|
||||
'analytic_tag_ids': [Command.set(self.analytic_tag_ids.ids)],
|
||||
'partner_id': self.order_id.partner_id.id,
|
||||
'sale_line_id': self.id,
|
||||
'active': True,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
|
||||
def _timesheet_create_project(self):
|
||||
""" Generate project for the given so line, and link it.
|
||||
:param project: record of project.project in which the task should be created
|
||||
:return task: record of the created task
|
||||
"""
|
||||
self.ensure_one()
|
||||
values = self._timesheet_create_project_prepare_values()
|
||||
if self.product_id.project_template_id:
|
||||
values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
|
||||
project = self.product_id.project_template_id.copy(values)
|
||||
project.tasks.write({
|
||||
'sale_line_id': self.id,
|
||||
'partner_id': self.order_id.partner_id.id,
|
||||
'email_from': self.order_id.partner_id.email,
|
||||
})
|
||||
# duplicating a project doesn't set the SO on sub-tasks
|
||||
project.tasks.filtered(lambda task: task.parent_id != False).write({
|
||||
'sale_line_id': self.id,
|
||||
'sale_order_id': self.order_id,
|
||||
})
|
||||
else:
|
||||
project_only_sol_count = self.env['sale.order.line'].search_count([
|
||||
('order_id', '=', self.order_id.id),
|
||||
('product_id.service_tracking', 'in', ['project_only', 'task_in_project']),
|
||||
])
|
||||
if project_only_sol_count == 1:
|
||||
values['name'] = "%s - [%s] %s" % (values['name'], self.product_id.default_code, self.product_id.name) if self.product_id.default_code else "%s - %s" % (values['name'], self.product_id.name)
|
||||
project = self.env['project.project'].create(values)
|
||||
|
||||
# Avoid new tasks to go to 'Undefined Stage'
|
||||
if not project.type_ids:
|
||||
project.type_ids = self.env['project.task.type'].create({'name': _('New')})
|
||||
|
||||
# link project as generated by current so line
|
||||
self.write({'project_id': project.id})
|
||||
return project
|
||||
|
||||
def _timesheet_create_task_prepare_values(self, project):
|
||||
self.ensure_one()
|
||||
planned_hours = self._convert_qty_company_hours(self.company_id)
|
||||
sale_line_name_parts = self.name.split('\n')
|
||||
title = sale_line_name_parts[0] or self.product_id.name
|
||||
description = '<br/>'.join(sale_line_name_parts[1:])
|
||||
return {
|
||||
'name': title if project.sale_line_id else '%s - %s' % (self.order_id.name or '', title),
|
||||
'planned_hours': planned_hours,
|
||||
'partner_id': self.order_id.partner_id.id,
|
||||
'email_from': self.order_id.partner_id.email,
|
||||
'description': description,
|
||||
'project_id': project.id,
|
||||
'sale_line_id': self.id,
|
||||
'sale_order_id': self.order_id.id,
|
||||
'company_id': project.company_id.id,
|
||||
'user_ids': False, # force non assigned task, as created as sudo()
|
||||
}
|
||||
|
||||
def _timesheet_create_task(self, project):
|
||||
""" Generate task for the given so line, and link it.
|
||||
:param project: record of project.project in which the task should be created
|
||||
:return task: record of the created task
|
||||
"""
|
||||
values = self._timesheet_create_task_prepare_values(project)
|
||||
task = self.env['project.task'].sudo().create(values)
|
||||
self.write({'task_id': task.id})
|
||||
# post message on task
|
||||
task_msg = _("This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name)
|
||||
task.message_post(body=task_msg)
|
||||
return task
|
||||
|
||||
def _timesheet_service_generation(self):
|
||||
""" For service lines, create the task or the project. If already exists, it simply links
|
||||
the existing one to the line.
|
||||
Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a
|
||||
new project/task. This explains the searches on 'sale_line_id' on project/task. This also
|
||||
implied if so line of generated task has been modified, we may regenerate it.
|
||||
"""
|
||||
so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
|
||||
so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])
|
||||
|
||||
# search so lines from SO of current so lines having their project generated, in order to check if the current one can
|
||||
# create its own project, or reuse the one of its order.
|
||||
map_so_project = {}
|
||||
if so_line_new_project:
|
||||
order_ids = self.mapped('order_id').ids
|
||||
so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '=', False)])
|
||||
map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
|
||||
so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '!=', False)])
|
||||
map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates}
|
||||
|
||||
# search the global project of current SO lines, in which create their task
|
||||
map_sol_project = {}
|
||||
if so_line_task_global_project:
|
||||
map_sol_project = {sol.id: sol.product_id.with_company(sol.company_id).project_id for sol in so_line_task_global_project}
|
||||
|
||||
def _can_create_project(sol):
|
||||
if not sol.project_id:
|
||||
if sol.product_id.project_template_id:
|
||||
return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates
|
||||
elif sol.order_id.id not in map_so_project:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _determine_project(so_line):
|
||||
"""Determine the project for this sale order line.
|
||||
Rules are different based on the service_tracking:
|
||||
|
||||
- 'project_only': the project_id can only come from the sale order line itself
|
||||
- 'task_in_project': the project_id comes from the sale order line only if no project_id was configured
|
||||
on the parent sale order"""
|
||||
|
||||
if so_line.product_id.service_tracking == 'project_only':
|
||||
return so_line.project_id
|
||||
elif so_line.product_id.service_tracking == 'task_in_project':
|
||||
return so_line.order_id.project_id or so_line.project_id
|
||||
|
||||
return False
|
||||
|
||||
# task_global_project: create task in global project
|
||||
for so_line in so_line_task_global_project:
|
||||
if not so_line.task_id:
|
||||
if map_sol_project.get(so_line.id) and so_line.product_uom_qty > 0:
|
||||
so_line._timesheet_create_task(project=map_sol_project[so_line.id])
|
||||
|
||||
# project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too.
|
||||
# if 'task_in_project' and project_id configured on SO, use that one instead
|
||||
for so_line in so_line_new_project:
|
||||
project = _determine_project(so_line)
|
||||
if not project and _can_create_project(so_line):
|
||||
project = so_line._timesheet_create_project()
|
||||
if so_line.product_id.project_template_id:
|
||||
map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
|
||||
else:
|
||||
map_so_project[so_line.order_id.id] = project
|
||||
elif not project:
|
||||
# Attach subsequent SO lines to the created project
|
||||
so_line.project_id = (
|
||||
map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id))
|
||||
or map_so_project.get(so_line.order_id.id)
|
||||
)
|
||||
if so_line.product_id.service_tracking == 'task_in_project':
|
||||
if not project:
|
||||
if so_line.product_id.project_template_id:
|
||||
project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
|
||||
else:
|
||||
project = map_so_project[so_line.order_id.id]
|
||||
if not so_line.task_id:
|
||||
so_line._timesheet_create_task(project=project)
|
||||
|
||||
def _prepare_invoice_line(self, **optional_values):
|
||||
"""
|
||||
If the sale order line isn't linked to a sale order which already have a default analytic account,
|
||||
this method allows to retrieve the analytic account which is linked to project or task directly linked
|
||||
to this sale order line, or the analytic account of the project which uses this sale order line, if it exists.
|
||||
"""
|
||||
values = super(SaleOrderLine, self)._prepare_invoice_line(**optional_values)
|
||||
if not values.get('analytic_account_id'):
|
||||
if self.task_id.analytic_account_id:
|
||||
values['analytic_account_id'] = self.task_id._get_task_analytic_account_id().id
|
||||
elif self.project_id.analytic_account_id:
|
||||
values['analytic_account_id'] = self.project_id.analytic_account_id.id
|
||||
elif self.is_service and not self.is_expense:
|
||||
task_analytic_account_id = self.env['project.task'].read_group([
|
||||
('sale_line_id', '=', self.id),
|
||||
('analytic_account_id', '!=', False),
|
||||
], ['analytic_account_id'], ['analytic_account_id'])
|
||||
project_analytic_account_id = self.env['project.project'].read_group([
|
||||
('analytic_account_id', '!=', False),
|
||||
'|',
|
||||
('sale_line_id', '=', self.id),
|
||||
'&',
|
||||
('tasks.sale_line_id', '=', self.id),
|
||||
('tasks.analytic_account_id', '=', False)
|
||||
], ['analytic_account_id'], ['analytic_account_id'])
|
||||
analytic_account_ids = {rec['analytic_account_id'][0] for rec in (task_analytic_account_id + project_analytic_account_id)}
|
||||
if len(analytic_account_ids) == 1:
|
||||
values['analytic_account_id'] = analytic_account_ids.pop()
|
||||
if self.task_id.analytic_tag_ids:
|
||||
values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids]
|
||||
if self.task_id.analytic_tag_ids or self.task_id.project_id.analytic_tag_ids:
|
||||
values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids | self.task_id.project_id.analytic_tag_ids]
|
||||
elif self.is_service and not self.is_expense:
|
||||
tag_ids = self.env['account.analytic.tag'].search([
|
||||
'|', ('task_ids.sale_line_id', '=', self.id), ('project_ids.sale_line_id', '=', self.id)
|
||||
])
|
||||
values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in tag_ids]
|
||||
return values
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.tools.sql import column_exists, create_column
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
project_id = fields.Many2one(
|
||||
'project.project', 'Generated Project',
|
||||
index=True, copy=False, help="Project generated by the sales order item")
|
||||
task_id = fields.Many2one(
|
||||
'project.task', 'Generated Task',
|
||||
index=True, copy=False, help="Task generated by the sales order item")
|
||||
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_is_service(self):
|
||||
for so_line in self:
|
||||
so_line.is_service = so_line.product_id.type == 'service'
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_product_updatable(self):
|
||||
for line in self:
|
||||
if line.product_id.type == 'service' and line.state == 'sale':
|
||||
line.product_updatable = False
|
||||
else:
|
||||
super(SaleOrderLine, line)._compute_product_updatable()
|
||||
|
||||
def _auto_init(self):
|
||||
"""
|
||||
Create column to stop ORM from computing it himself (too slow)
|
||||
"""
|
||||
if not column_exists(self.env.cr, 'sale_order_line', 'is_service'):
|
||||
create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool')
|
||||
self.env.cr.execute("""
|
||||
UPDATE sale_order_line line
|
||||
SET is_service = (pt.type = 'service')
|
||||
FROM product_product pp
|
||||
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
|
||||
WHERE pp.id = line.product_id
|
||||
""")
|
||||
return super()._auto_init()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super().create(vals_list)
|
||||
# Do not generate task/project when expense SO line, but allow
|
||||
# generate task with hours=0.
|
||||
for line in lines:
|
||||
if line.state == 'sale' and not line.is_expense:
|
||||
line.sudo()._timesheet_service_generation()
|
||||
# if the SO line created a task, post a message on the order
|
||||
if line.task_id:
|
||||
msg_body = _("Task Created (%s): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (line.product_id.name, line.task_id.id, line.task_id.name)
|
||||
line.order_id.message_post(body=msg_body)
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
result = super().write(values)
|
||||
# changing the ordered quantity should change the planned hours on the
|
||||
# task, whatever the SO state. It will be blocked by the super in case
|
||||
# of a locked sale order.
|
||||
if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
|
||||
for line in self:
|
||||
if line.task_id and line.product_id.type == 'service':
|
||||
planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
|
||||
line.task_id.write({'planned_hours': planned_hours})
|
||||
return result
|
||||
|
||||
###########################################
|
||||
# Service : Project and task generation
|
||||
###########################################
|
||||
|
||||
def _convert_qty_company_hours(self, dest_company):
|
||||
return self.product_uom_qty
|
||||
|
||||
def _timesheet_create_project_prepare_values(self):
|
||||
"""Generate project values"""
|
||||
account = self.order_id.analytic_account_id
|
||||
if not account:
|
||||
service_products = self.order_id.order_line.product_id.filtered(lambda p: p.type == 'service' and p.default_code)
|
||||
default_code = service_products.default_code if len(service_products) == 1 else None
|
||||
self.order_id._create_analytic_account(prefix=default_code)
|
||||
account = self.order_id.analytic_account_id
|
||||
|
||||
# create the project or duplicate one
|
||||
return {
|
||||
'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
|
||||
'analytic_account_id': account.id,
|
||||
'analytic_tag_ids': [Command.set(self.analytic_tag_ids.ids)],
|
||||
'partner_id': self.order_id.partner_id.id,
|
||||
'sale_line_id': self.id,
|
||||
'active': True,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
|
||||
def _timesheet_create_project(self):
|
||||
""" Generate project for the given so line, and link it.
|
||||
:param project: record of project.project in which the task should be created
|
||||
:return task: record of the created task
|
||||
"""
|
||||
self.ensure_one()
|
||||
values = self._timesheet_create_project_prepare_values()
|
||||
if self.product_id.project_template_id:
|
||||
values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
|
||||
project = self.product_id.project_template_id.copy(values)
|
||||
project.tasks.write({
|
||||
'sale_line_id': self.id,
|
||||
'partner_id': self.order_id.partner_id.id,
|
||||
'email_from': self.order_id.partner_id.email,
|
||||
})
|
||||
# duplicating a project doesn't set the SO on sub-tasks
|
||||
project.tasks.filtered('parent_id').write({
|
||||
'sale_line_id': self.id,
|
||||
'sale_order_id': self.order_id,
|
||||
})
|
||||
else:
|
||||
project_only_sol_count = self.env['sale.order.line'].search_count([
|
||||
('order_id', '=', self.order_id.id),
|
||||
('product_id.service_tracking', 'in', ['project_only', 'task_in_project']),
|
||||
])
|
||||
if project_only_sol_count == 1:
|
||||
values['name'] = "%s - [%s] %s" % (values['name'], self.product_id.default_code, self.product_id.name) if self.product_id.default_code else "%s - %s" % (values['name'], self.product_id.name)
|
||||
project = self.env['project.project'].create(values)
|
||||
|
||||
# Avoid new tasks to go to 'Undefined Stage'
|
||||
if not project.type_ids:
|
||||
project.type_ids = self.env['project.task.type'].create({'name': _('New')})
|
||||
|
||||
# link project as generated by current so line
|
||||
self.write({'project_id': project.id})
|
||||
return project
|
||||
|
||||
def _timesheet_create_task_prepare_values(self, project):
|
||||
self.ensure_one()
|
||||
planned_hours = self._convert_qty_company_hours(self.company_id)
|
||||
sale_line_name_parts = self.name.split('\n')
|
||||
title = sale_line_name_parts[0] or self.product_id.name
|
||||
description = '<br/>'.join(sale_line_name_parts[1:])
|
||||
return {
|
||||
'name': title if project.sale_line_id else '%s - %s' % (self.order_id.name or '', title),
|
||||
'planned_hours': planned_hours,
|
||||
'partner_id': self.order_id.partner_id.id,
|
||||
'email_from': self.order_id.partner_id.email,
|
||||
'description': description,
|
||||
'project_id': project.id,
|
||||
'sale_line_id': self.id,
|
||||
'sale_order_id': self.order_id.id,
|
||||
'company_id': project.company_id.id,
|
||||
'user_ids': False, # force non assigned task, as created as sudo()
|
||||
}
|
||||
|
||||
def _timesheet_create_task(self, project):
|
||||
""" Generate task for the given so line, and link it.
|
||||
:param project: record of project.project in which the task should be created
|
||||
:return task: record of the created task
|
||||
"""
|
||||
values = self._timesheet_create_task_prepare_values(project)
|
||||
task = self.env['project.task'].sudo().create(values)
|
||||
self.write({'task_id': task.id})
|
||||
# post message on task
|
||||
task_msg = _("This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name)
|
||||
task.message_post(body=task_msg)
|
||||
return task
|
||||
|
||||
def _timesheet_service_generation(self):
|
||||
""" For service lines, create the task or the project. If already exists, it simply links
|
||||
the existing one to the line.
|
||||
Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a
|
||||
new project/task. This explains the searches on 'sale_line_id' on project/task. This also
|
||||
implied if so line of generated task has been modified, we may regenerate it.
|
||||
"""
|
||||
so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
|
||||
so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])
|
||||
|
||||
# search so lines from SO of current so lines having their project generated, in order to check if the current one can
|
||||
# create its own project, or reuse the one of its order.
|
||||
map_so_project = {}
|
||||
if so_line_new_project:
|
||||
order_ids = self.mapped('order_id').ids
|
||||
so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '=', False)])
|
||||
map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
|
||||
so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '!=', False)])
|
||||
map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates}
|
||||
|
||||
# search the global project of current SO lines, in which create their task
|
||||
map_sol_project = {}
|
||||
if so_line_task_global_project:
|
||||
map_sol_project = {sol.id: sol.product_id.with_company(sol.company_id).project_id for sol in so_line_task_global_project}
|
||||
|
||||
def _can_create_project(sol):
|
||||
if not sol.project_id:
|
||||
if sol.product_id.project_template_id:
|
||||
return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates
|
||||
elif sol.order_id.id not in map_so_project:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _determine_project(so_line):
|
||||
"""Determine the project for this sale order line.
|
||||
Rules are different based on the service_tracking:
|
||||
|
||||
- 'project_only': the project_id can only come from the sale order line itself
|
||||
- 'task_in_project': the project_id comes from the sale order line only if no project_id was configured
|
||||
on the parent sale order"""
|
||||
|
||||
if so_line.product_id.service_tracking == 'project_only':
|
||||
return so_line.project_id
|
||||
elif so_line.product_id.service_tracking == 'task_in_project':
|
||||
return so_line.order_id.project_id or so_line.project_id
|
||||
|
||||
return False
|
||||
|
||||
# task_global_project: create task in global project
|
||||
for so_line in so_line_task_global_project:
|
||||
if not so_line.task_id:
|
||||
if map_sol_project.get(so_line.id) and so_line.product_uom_qty > 0:
|
||||
so_line._timesheet_create_task(project=map_sol_project[so_line.id])
|
||||
|
||||
# project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too.
|
||||
# if 'task_in_project' and project_id configured on SO, use that one instead
|
||||
for so_line in so_line_new_project:
|
||||
project = _determine_project(so_line)
|
||||
if not project and _can_create_project(so_line):
|
||||
project = so_line._timesheet_create_project()
|
||||
if so_line.product_id.project_template_id:
|
||||
map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
|
||||
else:
|
||||
map_so_project[so_line.order_id.id] = project
|
||||
elif not project:
|
||||
# Attach subsequent SO lines to the created project
|
||||
so_line.project_id = (
|
||||
map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id))
|
||||
or map_so_project.get(so_line.order_id.id)
|
||||
)
|
||||
if so_line.product_id.service_tracking == 'task_in_project':
|
||||
if not project:
|
||||
if so_line.product_id.project_template_id:
|
||||
project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
|
||||
else:
|
||||
project = map_so_project[so_line.order_id.id]
|
||||
if not so_line.task_id:
|
||||
so_line._timesheet_create_task(project=project)
|
||||
|
||||
def _prepare_invoice_line(self, **optional_values):
|
||||
"""
|
||||
If the sale order line isn't linked to a sale order which already have a default analytic account,
|
||||
this method allows to retrieve the analytic account which is linked to project or task directly linked
|
||||
to this sale order line, or the analytic account of the project which uses this sale order line, if it exists.
|
||||
"""
|
||||
values = super(SaleOrderLine, self)._prepare_invoice_line(**optional_values)
|
||||
if not values.get('analytic_account_id'):
|
||||
if self.task_id.analytic_account_id:
|
||||
values['analytic_account_id'] = self.task_id._get_task_analytic_account_id().id
|
||||
elif self.project_id.analytic_account_id:
|
||||
values['analytic_account_id'] = self.project_id.analytic_account_id.id
|
||||
elif self.is_service and not self.is_expense:
|
||||
task_analytic_account_id = self.env['project.task'].read_group([
|
||||
('sale_line_id', '=', self.id),
|
||||
('analytic_account_id', '!=', False),
|
||||
], ['analytic_account_id'], ['analytic_account_id'])
|
||||
project_analytic_account_id = self.env['project.project'].read_group([
|
||||
('analytic_account_id', '!=', False),
|
||||
'|',
|
||||
('sale_line_id', '=', self.id),
|
||||
'&',
|
||||
('tasks.sale_line_id', '=', self.id),
|
||||
('tasks.analytic_account_id', '=', False)
|
||||
], ['analytic_account_id'], ['analytic_account_id'])
|
||||
analytic_account_ids = {rec['analytic_account_id'][0] for rec in (task_analytic_account_id + project_analytic_account_id)}
|
||||
if len(analytic_account_ids) == 1:
|
||||
values['analytic_account_id'] = analytic_account_ids.pop()
|
||||
if self.task_id.analytic_tag_ids:
|
||||
values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids]
|
||||
if self.task_id.analytic_tag_ids or self.task_id.project_id.analytic_tag_ids:
|
||||
values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids | self.task_id.project_id.analytic_tag_ids]
|
||||
elif self.is_service and not self.is_expense:
|
||||
tag_ids = self.env['account.analytic.tag'].search([
|
||||
'|', ('task_ids.sale_line_id', '=', self.id), ('project_ids.sale_line_id', '=', self.id)
|
||||
])
|
||||
values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in tag_ids]
|
||||
return values
|
||||
@@ -3,4 +3,5 @@
|
||||
|
||||
from . import product_template
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import purchase_order
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
@@ -77,239 +73,3 @@ class SaleOrder(models.Model):
|
||||
'sale_orders': sale_order_lines.mapped('order_id'),
|
||||
'sale_order_lines': sale_order_lines,
|
||||
})
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.")
|
||||
purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count')
|
||||
|
||||
@api.depends('purchase_line_ids')
|
||||
def _compute_purchase_count(self):
|
||||
database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id'])
|
||||
mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data])
|
||||
for line in self:
|
||||
line.purchase_line_count = mapped_data.get(line.id, 0)
|
||||
|
||||
@api.onchange('product_uom_qty')
|
||||
def _onchange_service_product_uom_qty(self):
|
||||
if self.state == 'sale' and self.product_id.type == 'service' and self.product_id.service_to_purchase:
|
||||
if self.product_uom_qty < self._origin.product_uom_qty:
|
||||
if self.product_uom_qty < self.qty_delivered:
|
||||
return {}
|
||||
warning_mess = {
|
||||
'title': _('Ordered quantity decreased!'),
|
||||
'message': _('You are decreasing the ordered quantity! Do not forget to manually update the purchase order if needed.'),
|
||||
}
|
||||
return {'warning': warning_mess}
|
||||
return {}
|
||||
|
||||
# --------------------------
|
||||
# CRUD
|
||||
# --------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values):
|
||||
lines = super(SaleOrderLine, self).create(values)
|
||||
# Do not generate purchase when expense SO line since the product is already delivered
|
||||
lines.filtered(
|
||||
lambda line: line.state == 'sale' and not line.is_expense
|
||||
)._purchase_service_generation()
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
increased_lines = None
|
||||
decreased_lines = None
|
||||
increased_values = {}
|
||||
decreased_values = {}
|
||||
if 'product_uom_qty' in values:
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
increased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
|
||||
decreased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == 1)
|
||||
increased_values = {line.id: line.product_uom_qty for line in increased_lines}
|
||||
decreased_values = {line.id: line.product_uom_qty for line in decreased_lines}
|
||||
|
||||
result = super(SaleOrderLine, self).write(values)
|
||||
|
||||
if increased_lines:
|
||||
increased_lines._purchase_increase_ordered_qty(values['product_uom_qty'], increased_values)
|
||||
if decreased_lines:
|
||||
decreased_lines._purchase_decrease_ordered_qty(values['product_uom_qty'], decreased_values)
|
||||
return result
|
||||
|
||||
# --------------------------
|
||||
# Business Methods
|
||||
# --------------------------
|
||||
|
||||
def _purchase_decrease_ordered_qty(self, new_qty, origin_values):
|
||||
""" Decrease the quantity from SO line will add a next acitivities on the related purchase order
|
||||
:param new_qty: new quantity (lower than the current one on SO line), expressed
|
||||
in UoM of SO line.
|
||||
:param origin_values: map from sale line id to old value for the ordered quantity (dict)
|
||||
"""
|
||||
purchase_to_notify_map = {} # map PO -> set(SOL)
|
||||
last_purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.ids)])
|
||||
for purchase_line in last_purchase_lines:
|
||||
purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line'])
|
||||
purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id
|
||||
|
||||
# create next activity
|
||||
for purchase_order, sale_lines in purchase_to_notify_map.items():
|
||||
render_context = {
|
||||
'sale_lines': sale_lines,
|
||||
'sale_orders': sale_lines.mapped('order_id'),
|
||||
'origin_values': origin_values,
|
||||
}
|
||||
purchase_order._activity_schedule_with_view('mail.mail_activity_data_warning',
|
||||
user_id=purchase_order.user_id.id or self.env.uid,
|
||||
views_or_xmlid='sale_purchase.exception_purchase_on_sale_quantity_decreased',
|
||||
render_context=render_context)
|
||||
|
||||
def _purchase_increase_ordered_qty(self, new_qty, origin_values):
|
||||
""" Increase the quantity on the related purchase lines
|
||||
:param new_qty: new quantity (higher than the current one on SO line), expressed
|
||||
in UoM of SO line.
|
||||
:param origin_values: map from sale line id to old value for the ordered quantity (dict)
|
||||
"""
|
||||
for line in self:
|
||||
last_purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', line.id)], order='create_date DESC', limit=1)
|
||||
if last_purchase_line.state in ['draft', 'sent', 'to approve']: # update qty for draft PO lines
|
||||
quantity = line.product_uom._compute_quantity(new_qty, last_purchase_line.product_uom)
|
||||
last_purchase_line.write({'product_qty': quantity})
|
||||
elif last_purchase_line.state in ['purchase', 'done', 'cancel']: # create new PO, by forcing the quantity as the difference from SO line
|
||||
quantity = line.product_uom._compute_quantity(new_qty - origin_values.get(line.id, 0.0), last_purchase_line.product_uom)
|
||||
line._purchase_service_create(quantity=quantity)
|
||||
|
||||
def _purchase_get_date_order(self, supplierinfo):
|
||||
""" return the ordered date for the purchase order, computed as : SO commitment date - supplier delay """
|
||||
commitment_date = fields.Datetime.from_string(self.order_id.commitment_date or fields.Datetime.now())
|
||||
return commitment_date - relativedelta(days=int(supplierinfo.delay))
|
||||
|
||||
def _purchase_service_prepare_order_values(self, supplierinfo):
|
||||
""" Returns the values to create the purchase order from the current SO line.
|
||||
:param supplierinfo: record of product.supplierinfo
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner_supplier = supplierinfo.partner_id
|
||||
fpos = self.env['account.fiscal.position'].sudo()._get_fiscal_position(partner_supplier)
|
||||
date_order = self._purchase_get_date_order(supplierinfo)
|
||||
return {
|
||||
'partner_id': partner_supplier.id,
|
||||
'partner_ref': partner_supplier.ref,
|
||||
'company_id': self.company_id.id,
|
||||
'currency_id': partner_supplier.property_purchase_currency_id.id or self.env.company.currency_id.id,
|
||||
'dest_address_id': False, # False since only supported in stock
|
||||
'origin': self.order_id.name,
|
||||
'payment_term_id': partner_supplier.property_supplier_payment_term_id.id,
|
||||
'date_order': date_order,
|
||||
'fiscal_position_id': fpos.id,
|
||||
}
|
||||
|
||||
def _purchase_service_prepare_line_values(self, purchase_order, quantity=False):
|
||||
""" Returns the values to create the purchase order line from the current SO line.
|
||||
:param purchase_order: record of purchase.order
|
||||
:rtype: dict
|
||||
:param quantity: the quantity to force on the PO line, expressed in SO line UoM
|
||||
"""
|
||||
self.ensure_one()
|
||||
# compute quantity from SO line UoM
|
||||
product_quantity = self.product_uom_qty
|
||||
if quantity:
|
||||
product_quantity = quantity
|
||||
|
||||
purchase_qty_uom = self.product_uom._compute_quantity(product_quantity, self.product_id.uom_po_id)
|
||||
|
||||
# determine vendor (real supplier, sharing the same partner as the one from the PO, but with more accurate informations like validity, quantity, ...)
|
||||
# Note: one partner can have multiple supplier info for the same product
|
||||
supplierinfo = self.product_id._select_seller(
|
||||
partner_id=purchase_order.partner_id,
|
||||
quantity=purchase_qty_uom,
|
||||
date=purchase_order.date_order and purchase_order.date_order.date(), # and purchase_order.date_order[:10],
|
||||
uom_id=self.product_id.uom_po_id
|
||||
)
|
||||
supplier_taxes = self.product_id.supplier_taxes_id.filtered(lambda t: t.company_id.id == self.company_id.id)
|
||||
taxes = purchase_order.fiscal_position_id.map_tax(supplier_taxes)
|
||||
|
||||
# compute unit price
|
||||
price_unit = 0.0
|
||||
if supplierinfo:
|
||||
price_unit = self.env['account.tax'].sudo()._fix_tax_included_price_company(
|
||||
supplierinfo.price, supplier_taxes, taxes, self.company_id)
|
||||
if purchase_order.currency_id and supplierinfo.currency_id != purchase_order.currency_id:
|
||||
price_unit = supplierinfo.currency_id._convert(price_unit, purchase_order.currency_id, purchase_order.company_id, fields.datetime.today())
|
||||
|
||||
return {
|
||||
'name': '[%s] %s' % (self.product_id.default_code, self.name) if self.product_id.default_code else self.name,
|
||||
'product_qty': purchase_qty_uom,
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom': self.product_id.uom_po_id.id,
|
||||
'price_unit': price_unit,
|
||||
'date_planned': fields.Date.from_string(purchase_order.date_order) + relativedelta(days=int(supplierinfo.delay)),
|
||||
'taxes_id': [(6, 0, taxes.ids)],
|
||||
'order_id': purchase_order.id,
|
||||
'sale_line_id': self.id,
|
||||
}
|
||||
|
||||
def _purchase_service_create(self, quantity=False):
|
||||
""" On Sales Order confirmation, some lines (services ones) can create a purchase order line and maybe a purchase order.
|
||||
If a line should create a RFQ, it will check for existing PO. If no one is find, the SO line will create one, then adds
|
||||
a new PO line. The created purchase order line will be linked to the SO line.
|
||||
:param quantity: the quantity to force on the PO line, expressed in SO line UoM
|
||||
"""
|
||||
PurchaseOrder = self.env['purchase.order']
|
||||
supplier_po_map = {}
|
||||
sale_line_purchase_map = {}
|
||||
for line in self:
|
||||
line = line.with_company(line.company_id)
|
||||
# determine vendor of the order (take the first matching company and product)
|
||||
suppliers = line.product_id._select_seller(quantity=line.product_uom_qty, uom_id=line.product_uom)
|
||||
if not suppliers:
|
||||
raise UserError(_("There is no vendor associated to the product %s. Please define a vendor for this product.") % (line.product_id.display_name,))
|
||||
supplierinfo = suppliers[0]
|
||||
partner_supplier = supplierinfo.partner_id
|
||||
|
||||
# determine (or create) PO
|
||||
purchase_order = supplier_po_map.get(partner_supplier.id)
|
||||
if not purchase_order:
|
||||
purchase_order = PurchaseOrder.search([
|
||||
('partner_id', '=', partner_supplier.id),
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', line.company_id.id),
|
||||
], limit=1)
|
||||
if not purchase_order:
|
||||
values = line._purchase_service_prepare_order_values(supplierinfo)
|
||||
purchase_order = PurchaseOrder.create(values)
|
||||
else: # update origin of existing PO
|
||||
so_name = line.order_id.name
|
||||
origins = []
|
||||
if purchase_order.origin:
|
||||
origins = purchase_order.origin.split(', ') + origins
|
||||
if so_name not in origins:
|
||||
origins += [so_name]
|
||||
purchase_order.write({
|
||||
'origin': ', '.join(origins)
|
||||
})
|
||||
supplier_po_map[partner_supplier.id] = purchase_order
|
||||
|
||||
# add a PO line to the PO
|
||||
values = line._purchase_service_prepare_line_values(purchase_order, quantity=quantity)
|
||||
purchase_line = line.env['purchase.order.line'].create(values)
|
||||
|
||||
# link the generated purchase to the SO line
|
||||
sale_line_purchase_map.setdefault(line, line.env['purchase.order.line'])
|
||||
sale_line_purchase_map[line] |= purchase_line
|
||||
return sale_line_purchase_map
|
||||
|
||||
def _purchase_service_generation(self):
|
||||
""" Create a Purchase for the first time from the sale line. If the SO line already created a PO, it
|
||||
will not create a second one.
|
||||
"""
|
||||
sale_line_purchase_map = {}
|
||||
for line in self:
|
||||
# Do not regenerate PO line if the SO line has already created one in the past (SO cancel/reconfirmation case)
|
||||
if line.product_id.service_to_purchase and not line.purchase_line_count:
|
||||
result = line._purchase_service_create()
|
||||
sale_line_purchase_map.update(result)
|
||||
return sale_line_purchase_map
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.")
|
||||
purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count')
|
||||
|
||||
@api.depends('purchase_line_ids')
|
||||
def _compute_purchase_count(self):
|
||||
database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id'])
|
||||
mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data])
|
||||
for line in self:
|
||||
line.purchase_line_count = mapped_data.get(line.id, 0)
|
||||
|
||||
@api.onchange('product_uom_qty')
|
||||
def _onchange_service_product_uom_qty(self):
|
||||
if self.state == 'sale' and self.product_id.type == 'service' and self.product_id.service_to_purchase:
|
||||
if self.product_uom_qty < self._origin.product_uom_qty:
|
||||
if self.product_uom_qty < self.qty_delivered:
|
||||
return {}
|
||||
warning_mess = {
|
||||
'title': _('Ordered quantity decreased!'),
|
||||
'message': _('You are decreasing the ordered quantity! Do not forget to manually update the purchase order if needed.'),
|
||||
}
|
||||
return {'warning': warning_mess}
|
||||
return {}
|
||||
|
||||
# --------------------------
|
||||
# CRUD
|
||||
# --------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values):
|
||||
lines = super(SaleOrderLine, self).create(values)
|
||||
# Do not generate purchase when expense SO line since the product is already delivered
|
||||
lines.filtered(
|
||||
lambda line: line.state == 'sale' and not line.is_expense
|
||||
)._purchase_service_generation()
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
increased_lines = None
|
||||
decreased_lines = None
|
||||
increased_values = {}
|
||||
decreased_values = {}
|
||||
if 'product_uom_qty' in values:
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
increased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
|
||||
decreased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == 1)
|
||||
increased_values = {line.id: line.product_uom_qty for line in increased_lines}
|
||||
decreased_values = {line.id: line.product_uom_qty for line in decreased_lines}
|
||||
|
||||
result = super(SaleOrderLine, self).write(values)
|
||||
|
||||
if increased_lines:
|
||||
increased_lines._purchase_increase_ordered_qty(values['product_uom_qty'], increased_values)
|
||||
if decreased_lines:
|
||||
decreased_lines._purchase_decrease_ordered_qty(values['product_uom_qty'], decreased_values)
|
||||
return result
|
||||
|
||||
# --------------------------
|
||||
# Business Methods
|
||||
# --------------------------
|
||||
|
||||
def _purchase_decrease_ordered_qty(self, new_qty, origin_values):
|
||||
""" Decrease the quantity from SO line will add a next acitivities on the related purchase order
|
||||
:param new_qty: new quantity (lower than the current one on SO line), expressed
|
||||
in UoM of SO line.
|
||||
:param origin_values: map from sale line id to old value for the ordered quantity (dict)
|
||||
"""
|
||||
purchase_to_notify_map = {} # map PO -> set(SOL)
|
||||
last_purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.ids)])
|
||||
for purchase_line in last_purchase_lines:
|
||||
purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line'])
|
||||
purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id
|
||||
|
||||
# create next activity
|
||||
for purchase_order, sale_lines in purchase_to_notify_map.items():
|
||||
render_context = {
|
||||
'sale_lines': sale_lines,
|
||||
'sale_orders': sale_lines.mapped('order_id'),
|
||||
'origin_values': origin_values,
|
||||
}
|
||||
purchase_order._activity_schedule_with_view('mail.mail_activity_data_warning',
|
||||
user_id=purchase_order.user_id.id or self.env.uid,
|
||||
views_or_xmlid='sale_purchase.exception_purchase_on_sale_quantity_decreased',
|
||||
render_context=render_context)
|
||||
|
||||
def _purchase_increase_ordered_qty(self, new_qty, origin_values):
|
||||
""" Increase the quantity on the related purchase lines
|
||||
:param new_qty: new quantity (higher than the current one on SO line), expressed
|
||||
in UoM of SO line.
|
||||
:param origin_values: map from sale line id to old value for the ordered quantity (dict)
|
||||
"""
|
||||
for line in self:
|
||||
last_purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', line.id)], order='create_date DESC', limit=1)
|
||||
if last_purchase_line.state in ['draft', 'sent', 'to approve']: # update qty for draft PO lines
|
||||
quantity = line.product_uom._compute_quantity(new_qty, last_purchase_line.product_uom)
|
||||
last_purchase_line.write({'product_qty': quantity})
|
||||
elif last_purchase_line.state in ['purchase', 'done', 'cancel']: # create new PO, by forcing the quantity as the difference from SO line
|
||||
quantity = line.product_uom._compute_quantity(new_qty - origin_values.get(line.id, 0.0), last_purchase_line.product_uom)
|
||||
line._purchase_service_create(quantity=quantity)
|
||||
|
||||
def _purchase_get_date_order(self, supplierinfo):
|
||||
""" return the ordered date for the purchase order, computed as : SO commitment date - supplier delay """
|
||||
commitment_date = fields.Datetime.from_string(self.order_id.commitment_date or fields.Datetime.now())
|
||||
return commitment_date - relativedelta(days=int(supplierinfo.delay))
|
||||
|
||||
def _purchase_service_prepare_order_values(self, supplierinfo):
|
||||
""" Returns the values to create the purchase order from the current SO line.
|
||||
:param supplierinfo: record of product.supplierinfo
|
||||
:rtype: dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner_supplier = supplierinfo.partner_id
|
||||
fpos = self.env['account.fiscal.position'].sudo()._get_fiscal_position(partner_supplier)
|
||||
date_order = self._purchase_get_date_order(supplierinfo)
|
||||
return {
|
||||
'partner_id': partner_supplier.id,
|
||||
'partner_ref': partner_supplier.ref,
|
||||
'company_id': self.company_id.id,
|
||||
'currency_id': partner_supplier.property_purchase_currency_id.id or self.env.company.currency_id.id,
|
||||
'dest_address_id': False, # False since only supported in stock
|
||||
'origin': self.order_id.name,
|
||||
'payment_term_id': partner_supplier.property_supplier_payment_term_id.id,
|
||||
'date_order': date_order,
|
||||
'fiscal_position_id': fpos.id,
|
||||
}
|
||||
|
||||
def _purchase_service_prepare_line_values(self, purchase_order, quantity=False):
|
||||
""" Returns the values to create the purchase order line from the current SO line.
|
||||
:param purchase_order: record of purchase.order
|
||||
:rtype: dict
|
||||
:param quantity: the quantity to force on the PO line, expressed in SO line UoM
|
||||
"""
|
||||
self.ensure_one()
|
||||
# compute quantity from SO line UoM
|
||||
product_quantity = self.product_uom_qty
|
||||
if quantity:
|
||||
product_quantity = quantity
|
||||
|
||||
purchase_qty_uom = self.product_uom._compute_quantity(product_quantity, self.product_id.uom_po_id)
|
||||
|
||||
# determine vendor (real supplier, sharing the same partner as the one from the PO, but with more accurate informations like validity, quantity, ...)
|
||||
# Note: one partner can have multiple supplier info for the same product
|
||||
supplierinfo = self.product_id._select_seller(
|
||||
partner_id=purchase_order.partner_id,
|
||||
quantity=purchase_qty_uom,
|
||||
date=purchase_order.date_order and purchase_order.date_order.date(), # and purchase_order.date_order[:10],
|
||||
uom_id=self.product_id.uom_po_id
|
||||
)
|
||||
supplier_taxes = self.product_id.supplier_taxes_id.filtered(lambda t: t.company_id.id == self.company_id.id)
|
||||
taxes = purchase_order.fiscal_position_id.map_tax(supplier_taxes)
|
||||
|
||||
# compute unit price
|
||||
price_unit = 0.0
|
||||
if supplierinfo:
|
||||
price_unit = self.env['account.tax'].sudo()._fix_tax_included_price_company(
|
||||
supplierinfo.price, supplier_taxes, taxes, self.company_id)
|
||||
if purchase_order.currency_id and supplierinfo.currency_id != purchase_order.currency_id:
|
||||
price_unit = supplierinfo.currency_id._convert(price_unit, purchase_order.currency_id, purchase_order.company_id, fields.datetime.today())
|
||||
|
||||
return {
|
||||
'name': '[%s] %s' % (self.product_id.default_code, self.name) if self.product_id.default_code else self.name,
|
||||
'product_qty': purchase_qty_uom,
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom': self.product_id.uom_po_id.id,
|
||||
'price_unit': price_unit,
|
||||
'date_planned': fields.Date.from_string(purchase_order.date_order) + relativedelta(days=int(supplierinfo.delay)),
|
||||
'taxes_id': [(6, 0, taxes.ids)],
|
||||
'order_id': purchase_order.id,
|
||||
'sale_line_id': self.id,
|
||||
}
|
||||
|
||||
def _purchase_service_create(self, quantity=False):
|
||||
""" On Sales Order confirmation, some lines (services ones) can create a purchase order line and maybe a purchase order.
|
||||
If a line should create a RFQ, it will check for existing PO. If no one is find, the SO line will create one, then adds
|
||||
a new PO line. The created purchase order line will be linked to the SO line.
|
||||
:param quantity: the quantity to force on the PO line, expressed in SO line UoM
|
||||
"""
|
||||
PurchaseOrder = self.env['purchase.order']
|
||||
supplier_po_map = {}
|
||||
sale_line_purchase_map = {}
|
||||
for line in self:
|
||||
line = line.with_company(line.company_id)
|
||||
# determine vendor of the order (take the first matching company and product)
|
||||
suppliers = line.product_id._select_seller(quantity=line.product_uom_qty, uom_id=line.product_uom)
|
||||
if not suppliers:
|
||||
raise UserError(_("There is no vendor associated to the product %s. Please define a vendor for this product.") % (line.product_id.display_name,))
|
||||
supplierinfo = suppliers[0]
|
||||
partner_supplier = supplierinfo.partner_id
|
||||
|
||||
# determine (or create) PO
|
||||
purchase_order = supplier_po_map.get(partner_supplier.id)
|
||||
if not purchase_order:
|
||||
purchase_order = PurchaseOrder.search([
|
||||
('partner_id', '=', partner_supplier.id),
|
||||
('state', '=', 'draft'),
|
||||
('company_id', '=', line.company_id.id),
|
||||
], limit=1)
|
||||
if not purchase_order:
|
||||
values = line._purchase_service_prepare_order_values(supplierinfo)
|
||||
purchase_order = PurchaseOrder.create(values)
|
||||
else: # update origin of existing PO
|
||||
so_name = line.order_id.name
|
||||
origins = []
|
||||
if purchase_order.origin:
|
||||
origins = purchase_order.origin.split(', ') + origins
|
||||
if so_name not in origins:
|
||||
origins += [so_name]
|
||||
purchase_order.write({
|
||||
'origin': ', '.join(origins)
|
||||
})
|
||||
supplier_po_map[partner_supplier.id] = purchase_order
|
||||
|
||||
# add a PO line to the PO
|
||||
values = line._purchase_service_prepare_line_values(purchase_order, quantity=quantity)
|
||||
purchase_line = line.env['purchase.order.line'].create(values)
|
||||
|
||||
# link the generated purchase to the SO line
|
||||
sale_line_purchase_map.setdefault(line, line.env['purchase.order.line'])
|
||||
sale_line_purchase_map[line] |= purchase_line
|
||||
return sale_line_purchase_map
|
||||
|
||||
def _purchase_service_generation(self):
|
||||
""" Create a Purchase for the first time from the sale line. If the SO line already created a PO, it
|
||||
will not create a second one.
|
||||
"""
|
||||
sale_line_purchase_map = {}
|
||||
for line in self:
|
||||
# Do not regenerate PO line if the SO line has already created one in the past (SO cancel/reconfirmation case)
|
||||
if line.product_id.service_to_purchase and not line.purchase_line_count:
|
||||
result = line._purchase_service_create()
|
||||
sale_line_purchase_map.update(result)
|
||||
return sale_line_purchase_map
|
||||
@@ -2,9 +2,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_move
|
||||
from . import res_company
|
||||
from . import sale_order
|
||||
from . import res_config_settings
|
||||
from . import stock
|
||||
from . import product_template
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import res_users
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import stock
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import float_compare
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -246,343 +242,3 @@ class SaleOrder(models.Model):
|
||||
if any(picking.state == 'done' for picking in order.picking_ids) and not order._context.get('disable_cancel_warning'):
|
||||
return True
|
||||
return res
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
|
||||
route_id = fields.Many2one('stock.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
|
||||
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
|
||||
product_type = fields.Selection(related='product_id.detailed_type')
|
||||
virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
|
||||
scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
|
||||
forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date')
|
||||
free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
|
||||
qty_available_today = fields.Float(compute='_compute_qty_at_date')
|
||||
warehouse_id = fields.Many2one(related='order_id.warehouse_id')
|
||||
qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
|
||||
is_mto = fields.Boolean(compute='_compute_is_mto')
|
||||
display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
|
||||
customer_lead = fields.Float(
|
||||
compute='_compute_customer_lead', store=True, readonly=False, precompute=True,
|
||||
inverse='_inverse_customer_lead')
|
||||
|
||||
@api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom')
|
||||
def _compute_qty_to_deliver(self):
|
||||
"""Compute the visibility of the inventory widget."""
|
||||
for line in self:
|
||||
line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
|
||||
if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0:
|
||||
if line.state == 'sale' and not line.move_ids:
|
||||
line.display_qty_widget = False
|
||||
else:
|
||||
line.display_qty_widget = True
|
||||
else:
|
||||
line.display_qty_widget = False
|
||||
|
||||
@api.depends(
|
||||
'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
|
||||
'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
|
||||
def _compute_qty_at_date(self):
|
||||
""" Compute the quantity forecasted of product at delivery date. There are
|
||||
two cases:
|
||||
1. The quotation has a commitment_date, we take it as delivery date
|
||||
2. The quotation hasn't commitment_date, we compute the estimated delivery
|
||||
date based on lead time"""
|
||||
treated = self.browse()
|
||||
# If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
|
||||
# Then used the forecasted data of the related stock.move
|
||||
for line in self.filtered(lambda l: l.state == 'sale'):
|
||||
if not line.display_qty_widget:
|
||||
continue
|
||||
moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id)
|
||||
line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
|
||||
line.qty_available_today = 0
|
||||
line.free_qty_today = 0
|
||||
for move in moves:
|
||||
line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
|
||||
line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
|
||||
line.scheduled_date = line.order_id.commitment_date or line._expected_date()
|
||||
line.virtual_available_at_date = False
|
||||
treated |= line
|
||||
|
||||
qty_processed_per_product = defaultdict(lambda: 0)
|
||||
grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
|
||||
# We first loop over the SO lines to group them by warehouse and schedule
|
||||
# date in order to batch the read of the quantities computed field.
|
||||
for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
|
||||
if not (line.product_id and line.display_qty_widget):
|
||||
continue
|
||||
grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
|
||||
|
||||
for (warehouse, scheduled_date), lines in grouped_lines.items():
|
||||
product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
|
||||
'qty_available',
|
||||
'free_qty',
|
||||
'virtual_available',
|
||||
])
|
||||
qties_per_product = {
|
||||
product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
|
||||
for product in product_qties
|
||||
}
|
||||
for line in lines:
|
||||
line.scheduled_date = scheduled_date
|
||||
qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
|
||||
line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
|
||||
line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
|
||||
line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
|
||||
line.forecast_expected_date = False
|
||||
product_qty = line.product_uom_qty
|
||||
if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
|
||||
line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
|
||||
line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
|
||||
line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
|
||||
product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id)
|
||||
qty_processed_per_product[line.product_id.id] += product_qty
|
||||
treated |= lines
|
||||
remaining = (self - treated)
|
||||
remaining.virtual_available_at_date = False
|
||||
remaining.scheduled_date = False
|
||||
remaining.forecast_expected_date = False
|
||||
remaining.free_qty_today = False
|
||||
remaining.qty_available_today = False
|
||||
|
||||
@api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids')
|
||||
def _compute_is_mto(self):
|
||||
""" Verify the route of the product based on the warehouse
|
||||
set 'is_available' at True if the product availability in stock does
|
||||
not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
|
||||
"""
|
||||
self.is_mto = False
|
||||
for line in self:
|
||||
if not line.display_qty_widget:
|
||||
continue
|
||||
product = line.product_id
|
||||
product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
|
||||
|
||||
# Check MTO
|
||||
mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
|
||||
if not mto_route:
|
||||
try:
|
||||
mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
|
||||
except UserError:
|
||||
# if route MTO not found in ir_model_data, we treat the product as in MTS
|
||||
pass
|
||||
|
||||
if mto_route and mto_route in product_routes:
|
||||
line.is_mto = True
|
||||
else:
|
||||
line.is_mto = False
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
|
||||
For SO line coming from expense, no picking should be generate: we don't manage stock for
|
||||
those lines, even if the product is a storable.
|
||||
"""
|
||||
super(SaleOrderLine, self)._compute_qty_delivered_method()
|
||||
|
||||
for line in self:
|
||||
if not line.is_expense and line.product_id.type in ['consu', 'product']:
|
||||
line.qty_delivered_method = 'stock_move'
|
||||
|
||||
@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
|
||||
outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves()
|
||||
for move in outgoing_moves:
|
||||
if move.state != 'done':
|
||||
continue
|
||||
qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
|
||||
for move in incoming_moves:
|
||||
if move.state != 'done':
|
||||
continue
|
||||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
|
||||
line.qty_delivered = qty
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super(SaleOrderLine, self).create(vals_list)
|
||||
lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
lines = self.env['sale.order.line']
|
||||
if 'product_uom_qty' in values:
|
||||
lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense)
|
||||
|
||||
previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
|
||||
res = super(SaleOrderLine, self).write(values)
|
||||
if lines:
|
||||
lines._action_launch_stock_rule(previous_product_uom_qty)
|
||||
return res
|
||||
|
||||
@api.depends('order_id.state')
|
||||
def _compute_invoice_status(self):
|
||||
def check_moves_state(moves):
|
||||
# All moves states are either 'done' or 'cancel', and there is at least one 'done'
|
||||
at_least_one_done = False
|
||||
for move in moves:
|
||||
if move.state not in ['done', 'cancel']:
|
||||
return False
|
||||
at_least_one_done = at_least_one_done or move.state == 'done'
|
||||
return at_least_one_done
|
||||
super(SaleOrderLine, self)._compute_invoice_status()
|
||||
for line in self:
|
||||
# We handle the following specific situation: a physical product is partially delivered,
|
||||
# but we would like to set its invoice status to 'Fully Invoiced'. The use case is for
|
||||
# products sold by weight, where the delivered quantity rarely matches exactly the
|
||||
# quantity ordered.
|
||||
if line.order_id.state == 'done'\
|
||||
and line.invoice_status == 'no'\
|
||||
and line.product_id.type in ['consu', 'product']\
|
||||
and line.product_id.invoice_policy == 'delivery'\
|
||||
and line.move_ids \
|
||||
and check_moves_state(line.move_ids):
|
||||
line.invoice_status = 'invoiced'
|
||||
|
||||
@api.depends('move_ids')
|
||||
def _compute_product_updatable(self):
|
||||
for line in self:
|
||||
if not line.move_ids.filtered(lambda m: m.state != 'cancel'):
|
||||
super(SaleOrderLine, line)._compute_product_updatable()
|
||||
else:
|
||||
line.product_updatable = False
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_customer_lead(self):
|
||||
super()._compute_customer_lead() # Reset customer_lead when the product is modified
|
||||
for line in self:
|
||||
line.customer_lead = line.product_id.sale_delay
|
||||
|
||||
def _inverse_customer_lead(self):
|
||||
for line in self:
|
||||
if line.state == 'sale' and not line.order_id.commitment_date:
|
||||
# Propagate deadline on related stock move
|
||||
line.move_ids.date_deadline = line.order_id.date_order + timedelta(days=line.customer_lead or 0.0)
|
||||
|
||||
def _prepare_procurement_values(self, group_id=False):
|
||||
""" Prepare specific key for moves or other components that will be created from a stock rule
|
||||
coming from a sale order line. This method could be override in order to add other custom key that could
|
||||
be used in move/po creation.
|
||||
"""
|
||||
values = super(SaleOrderLine, self)._prepare_procurement_values(group_id)
|
||||
self.ensure_one()
|
||||
# Use the delivery date if there is else use date_order and lead time
|
||||
date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0))
|
||||
date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead)
|
||||
values.update({
|
||||
'group_id': group_id,
|
||||
'sale_line_id': self.id,
|
||||
'date_planned': date_planned,
|
||||
'date_deadline': date_deadline,
|
||||
'route_ids': self.route_id,
|
||||
'warehouse_id': self.order_id.warehouse_id or False,
|
||||
'partner_id': self.order_id.partner_shipping_id.id,
|
||||
'product_description_variants': self._get_sale_order_line_multiline_description_variants(),
|
||||
'company_id': self.order_id.company_id,
|
||||
'product_packaging_id': self.product_packaging_id,
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_qty_procurement(self, previous_product_uom_qty=False):
|
||||
self.ensure_one()
|
||||
qty = 0.0
|
||||
outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
|
||||
for move in outgoing_moves:
|
||||
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
|
||||
for move in incoming_moves:
|
||||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
|
||||
return qty
|
||||
|
||||
def _get_outgoing_incoming_moves(self):
|
||||
outgoing_moves = self.env['stock.move']
|
||||
incoming_moves = self.env['stock.move']
|
||||
|
||||
moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id)
|
||||
if self._context.get('accrual_entry_date'):
|
||||
moves = moves.filtered(lambda r: fields.Date.to_date(r.date) <= self._context['accrual_entry_date'])
|
||||
|
||||
for move in moves:
|
||||
if move.location_dest_id.usage == "customer":
|
||||
if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
|
||||
outgoing_moves |= move
|
||||
elif move.location_dest_id.usage != "customer" and move.to_refund:
|
||||
incoming_moves |= move
|
||||
|
||||
return outgoing_moves, incoming_moves
|
||||
|
||||
def _get_procurement_group(self):
|
||||
return self.order_id.procurement_group_id
|
||||
|
||||
def _prepare_procurement_group_vals(self):
|
||||
return {
|
||||
'name': self.order_id.name,
|
||||
'move_type': self.order_id.picking_policy,
|
||||
'sale_id': self.order_id.id,
|
||||
'partner_id': self.order_id.partner_shipping_id.id,
|
||||
}
|
||||
|
||||
def _action_launch_stock_rule(self, previous_product_uom_qty=False):
|
||||
"""
|
||||
Launch procurement group run method with required/custom fields generated by a
|
||||
sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
|
||||
depending on the sale order line product rule.
|
||||
"""
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
procurements = []
|
||||
for line in self:
|
||||
line = line.with_company(line.company_id)
|
||||
if line.state != 'sale' or not line.product_id.type in ('consu','product'):
|
||||
continue
|
||||
qty = line._get_qty_procurement(previous_product_uom_qty)
|
||||
if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0:
|
||||
continue
|
||||
|
||||
group_id = line._get_procurement_group()
|
||||
if not group_id:
|
||||
group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
|
||||
line.order_id.procurement_group_id = group_id
|
||||
else:
|
||||
# In case the procurement group is already created and the order was
|
||||
# cancelled, we need to update certain values of the group.
|
||||
updated_vals = {}
|
||||
if group_id.partner_id != line.order_id.partner_shipping_id:
|
||||
updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
|
||||
if group_id.move_type != line.order_id.picking_policy:
|
||||
updated_vals.update({'move_type': line.order_id.picking_policy})
|
||||
if updated_vals:
|
||||
group_id.write(updated_vals)
|
||||
|
||||
values = line._prepare_procurement_values(group_id=group_id)
|
||||
product_qty = line.product_uom_qty - qty
|
||||
|
||||
line_uom = line.product_uom
|
||||
quant_uom = line.product_id.uom_id
|
||||
product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
|
||||
procurements.append(self.env['procurement.group'].Procurement(
|
||||
line.product_id, product_qty, procurement_uom,
|
||||
line.order_id.partner_shipping_id.property_stock_customer,
|
||||
line.name, line.order_id.name, line.order_id.company_id, values))
|
||||
if procurements:
|
||||
self.env['procurement.group'].run(procurements)
|
||||
|
||||
# This next block is currently needed only because the scheduler trigger is done by picking confirmation rather than stock.move confirmation
|
||||
orders = self.mapped('order_id')
|
||||
for order in orders:
|
||||
pickings_to_confirm = order.picking_ids.filtered(lambda p: p.state not in ['cancel', 'done'])
|
||||
if pickings_to_confirm:
|
||||
# Trigger the Scheduler for Pickings
|
||||
pickings_to_confirm.action_confirm()
|
||||
return True
|
||||
|
||||
def _update_line_quantity(self, values):
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
|
||||
if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
|
||||
raise UserError(_('The ordered quantity cannot be decreased below the amount already delivered. Instead, create a return in your inventory.'))
|
||||
super(SaleOrderLine, self)._update_line_quantity(values)
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import float_compare
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
|
||||
route_id = fields.Many2one('stock.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
|
||||
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
|
||||
product_type = fields.Selection(related='product_id.detailed_type')
|
||||
virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
|
||||
scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
|
||||
forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date')
|
||||
free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
|
||||
qty_available_today = fields.Float(compute='_compute_qty_at_date')
|
||||
warehouse_id = fields.Many2one(related='order_id.warehouse_id')
|
||||
qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
|
||||
is_mto = fields.Boolean(compute='_compute_is_mto')
|
||||
display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
|
||||
customer_lead = fields.Float(
|
||||
compute='_compute_customer_lead', store=True, readonly=False, precompute=True,
|
||||
inverse='_inverse_customer_lead')
|
||||
|
||||
@api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom')
|
||||
def _compute_qty_to_deliver(self):
|
||||
"""Compute the visibility of the inventory widget."""
|
||||
for line in self:
|
||||
line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
|
||||
if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0:
|
||||
if line.state == 'sale' and not line.move_ids:
|
||||
line.display_qty_widget = False
|
||||
else:
|
||||
line.display_qty_widget = True
|
||||
else:
|
||||
line.display_qty_widget = False
|
||||
|
||||
@api.depends(
|
||||
'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
|
||||
'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
|
||||
def _compute_qty_at_date(self):
|
||||
""" Compute the quantity forecasted of product at delivery date. There are
|
||||
two cases:
|
||||
1. The quotation has a commitment_date, we take it as delivery date
|
||||
2. The quotation hasn't commitment_date, we compute the estimated delivery
|
||||
date based on lead time"""
|
||||
treated = self.browse()
|
||||
# If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
|
||||
# Then used the forecasted data of the related stock.move
|
||||
for line in self.filtered(lambda l: l.state == 'sale'):
|
||||
if not line.display_qty_widget:
|
||||
continue
|
||||
moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id)
|
||||
line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
|
||||
line.qty_available_today = 0
|
||||
line.free_qty_today = 0
|
||||
for move in moves:
|
||||
line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
|
||||
line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
|
||||
line.scheduled_date = line.order_id.commitment_date or line._expected_date()
|
||||
line.virtual_available_at_date = False
|
||||
treated |= line
|
||||
|
||||
qty_processed_per_product = defaultdict(lambda: 0)
|
||||
grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
|
||||
# We first loop over the SO lines to group them by warehouse and schedule
|
||||
# date in order to batch the read of the quantities computed field.
|
||||
for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
|
||||
if not (line.product_id and line.display_qty_widget):
|
||||
continue
|
||||
grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
|
||||
|
||||
for (warehouse, scheduled_date), lines in grouped_lines.items():
|
||||
product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
|
||||
'qty_available',
|
||||
'free_qty',
|
||||
'virtual_available',
|
||||
])
|
||||
qties_per_product = {
|
||||
product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
|
||||
for product in product_qties
|
||||
}
|
||||
for line in lines:
|
||||
line.scheduled_date = scheduled_date
|
||||
qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
|
||||
line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
|
||||
line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
|
||||
line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
|
||||
line.forecast_expected_date = False
|
||||
product_qty = line.product_uom_qty
|
||||
if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
|
||||
line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
|
||||
line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
|
||||
line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
|
||||
product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id)
|
||||
qty_processed_per_product[line.product_id.id] += product_qty
|
||||
treated |= lines
|
||||
remaining = (self - treated)
|
||||
remaining.virtual_available_at_date = False
|
||||
remaining.scheduled_date = False
|
||||
remaining.forecast_expected_date = False
|
||||
remaining.free_qty_today = False
|
||||
remaining.qty_available_today = False
|
||||
|
||||
@api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids')
|
||||
def _compute_is_mto(self):
|
||||
""" Verify the route of the product based on the warehouse
|
||||
set 'is_available' at True if the product availability in stock does
|
||||
not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
|
||||
"""
|
||||
self.is_mto = False
|
||||
for line in self:
|
||||
if not line.display_qty_widget:
|
||||
continue
|
||||
product = line.product_id
|
||||
product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
|
||||
|
||||
# Check MTO
|
||||
mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
|
||||
if not mto_route:
|
||||
try:
|
||||
mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
|
||||
except UserError:
|
||||
# if route MTO not found in ir_model_data, we treat the product as in MTS
|
||||
pass
|
||||
|
||||
if mto_route and mto_route in product_routes:
|
||||
line.is_mto = True
|
||||
else:
|
||||
line.is_mto = False
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
|
||||
For SO line coming from expense, no picking should be generate: we don't manage stock for
|
||||
those lines, even if the product is a storable.
|
||||
"""
|
||||
super(SaleOrderLine, self)._compute_qty_delivered_method()
|
||||
|
||||
for line in self:
|
||||
if not line.is_expense and line.product_id.type in ['consu', 'product']:
|
||||
line.qty_delivered_method = 'stock_move'
|
||||
|
||||
@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
|
||||
outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves()
|
||||
for move in outgoing_moves:
|
||||
if move.state != 'done':
|
||||
continue
|
||||
qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
|
||||
for move in incoming_moves:
|
||||
if move.state != 'done':
|
||||
continue
|
||||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
|
||||
line.qty_delivered = qty
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
lines = super(SaleOrderLine, self).create(vals_list)
|
||||
lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
lines = self.env['sale.order.line']
|
||||
if 'product_uom_qty' in values:
|
||||
lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense)
|
||||
|
||||
previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
|
||||
res = super(SaleOrderLine, self).write(values)
|
||||
if lines:
|
||||
lines._action_launch_stock_rule(previous_product_uom_qty)
|
||||
return res
|
||||
|
||||
@api.depends('order_id.state')
|
||||
def _compute_invoice_status(self):
|
||||
def check_moves_state(moves):
|
||||
# All moves states are either 'done' or 'cancel', and there is at least one 'done'
|
||||
at_least_one_done = False
|
||||
for move in moves:
|
||||
if move.state not in ['done', 'cancel']:
|
||||
return False
|
||||
at_least_one_done = at_least_one_done or move.state == 'done'
|
||||
return at_least_one_done
|
||||
super(SaleOrderLine, self)._compute_invoice_status()
|
||||
for line in self:
|
||||
# We handle the following specific situation: a physical product is partially delivered,
|
||||
# but we would like to set its invoice status to 'Fully Invoiced'. The use case is for
|
||||
# products sold by weight, where the delivered quantity rarely matches exactly the
|
||||
# quantity ordered.
|
||||
if line.order_id.state == 'done'\
|
||||
and line.invoice_status == 'no'\
|
||||
and line.product_id.type in ['consu', 'product']\
|
||||
and line.product_id.invoice_policy == 'delivery'\
|
||||
and line.move_ids \
|
||||
and check_moves_state(line.move_ids):
|
||||
line.invoice_status = 'invoiced'
|
||||
|
||||
@api.depends('move_ids')
|
||||
def _compute_product_updatable(self):
|
||||
for line in self:
|
||||
if not line.move_ids.filtered(lambda m: m.state != 'cancel'):
|
||||
super(SaleOrderLine, line)._compute_product_updatable()
|
||||
else:
|
||||
line.product_updatable = False
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_customer_lead(self):
|
||||
super()._compute_customer_lead() # Reset customer_lead when the product is modified
|
||||
for line in self:
|
||||
line.customer_lead = line.product_id.sale_delay
|
||||
|
||||
def _inverse_customer_lead(self):
|
||||
for line in self:
|
||||
if line.state == 'sale' and not line.order_id.commitment_date:
|
||||
# Propagate deadline on related stock move
|
||||
line.move_ids.date_deadline = line.order_id.date_order + timedelta(days=line.customer_lead or 0.0)
|
||||
|
||||
def _prepare_procurement_values(self, group_id=False):
|
||||
""" Prepare specific key for moves or other components that will be created from a stock rule
|
||||
coming from a sale order line. This method could be override in order to add other custom key that could
|
||||
be used in move/po creation.
|
||||
"""
|
||||
values = super(SaleOrderLine, self)._prepare_procurement_values(group_id)
|
||||
self.ensure_one()
|
||||
# Use the delivery date if there is else use date_order and lead time
|
||||
date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0))
|
||||
date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead)
|
||||
values.update({
|
||||
'group_id': group_id,
|
||||
'sale_line_id': self.id,
|
||||
'date_planned': date_planned,
|
||||
'date_deadline': date_deadline,
|
||||
'route_ids': self.route_id,
|
||||
'warehouse_id': self.order_id.warehouse_id or False,
|
||||
'partner_id': self.order_id.partner_shipping_id.id,
|
||||
'product_description_variants': self._get_sale_order_line_multiline_description_variants(),
|
||||
'company_id': self.order_id.company_id,
|
||||
'product_packaging_id': self.product_packaging_id,
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_qty_procurement(self, previous_product_uom_qty=False):
|
||||
self.ensure_one()
|
||||
qty = 0.0
|
||||
outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
|
||||
for move in outgoing_moves:
|
||||
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
|
||||
for move in incoming_moves:
|
||||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
|
||||
return qty
|
||||
|
||||
def _get_outgoing_incoming_moves(self):
|
||||
outgoing_moves = self.env['stock.move']
|
||||
incoming_moves = self.env['stock.move']
|
||||
|
||||
moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id)
|
||||
if self._context.get('accrual_entry_date'):
|
||||
moves = moves.filtered(lambda r: fields.Date.to_date(r.date) <= self._context['accrual_entry_date'])
|
||||
|
||||
for move in moves:
|
||||
if move.location_dest_id.usage == "customer":
|
||||
if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
|
||||
outgoing_moves |= move
|
||||
elif move.location_dest_id.usage != "customer" and move.to_refund:
|
||||
incoming_moves |= move
|
||||
|
||||
return outgoing_moves, incoming_moves
|
||||
|
||||
def _get_procurement_group(self):
|
||||
return self.order_id.procurement_group_id
|
||||
|
||||
def _prepare_procurement_group_vals(self):
|
||||
return {
|
||||
'name': self.order_id.name,
|
||||
'move_type': self.order_id.picking_policy,
|
||||
'sale_id': self.order_id.id,
|
||||
'partner_id': self.order_id.partner_shipping_id.id,
|
||||
}
|
||||
|
||||
def _action_launch_stock_rule(self, previous_product_uom_qty=False):
|
||||
"""
|
||||
Launch procurement group run method with required/custom fields generated by a
|
||||
sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
|
||||
depending on the sale order line product rule.
|
||||
"""
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
procurements = []
|
||||
for line in self:
|
||||
line = line.with_company(line.company_id)
|
||||
if line.state != 'sale' or not line.product_id.type in ('consu', 'product'):
|
||||
continue
|
||||
qty = line._get_qty_procurement(previous_product_uom_qty)
|
||||
if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0:
|
||||
continue
|
||||
|
||||
group_id = line._get_procurement_group()
|
||||
if not group_id:
|
||||
group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
|
||||
line.order_id.procurement_group_id = group_id
|
||||
else:
|
||||
# In case the procurement group is already created and the order was
|
||||
# cancelled, we need to update certain values of the group.
|
||||
updated_vals = {}
|
||||
if group_id.partner_id != line.order_id.partner_shipping_id:
|
||||
updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
|
||||
if group_id.move_type != line.order_id.picking_policy:
|
||||
updated_vals.update({'move_type': line.order_id.picking_policy})
|
||||
if updated_vals:
|
||||
group_id.write(updated_vals)
|
||||
|
||||
values = line._prepare_procurement_values(group_id=group_id)
|
||||
product_qty = line.product_uom_qty - qty
|
||||
|
||||
line_uom = line.product_uom
|
||||
quant_uom = line.product_id.uom_id
|
||||
product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
|
||||
procurements.append(self.env['procurement.group'].Procurement(
|
||||
line.product_id, product_qty, procurement_uom,
|
||||
line.order_id.partner_shipping_id.property_stock_customer,
|
||||
line.name, line.order_id.name, line.order_id.company_id, values))
|
||||
if procurements:
|
||||
self.env['procurement.group'].run(procurements)
|
||||
|
||||
# This next block is currently needed only because the scheduler trigger is done by picking confirmation rather than stock.move confirmation
|
||||
orders = self.mapped('order_id')
|
||||
for order in orders:
|
||||
pickings_to_confirm = order.picking_ids.filtered(lambda p: p.state not in ['cancel', 'done'])
|
||||
if pickings_to_confirm:
|
||||
# Trigger the Scheduler for Pickings
|
||||
pickings_to_confirm.action_confirm()
|
||||
return True
|
||||
|
||||
def _update_line_quantity(self, values):
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
|
||||
if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
|
||||
raise UserError(_('The ordered quantity cannot be decreased below the amount already delivered. Instead, create a return in your inventory.'))
|
||||
super(SaleOrderLine, self)._update_line_quantity(values)
|
||||
Reference in New Issue
Block a user