[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:
Odoo's Mergebot
2022-02-25 12:40:22 +01:00
committed by GitHub
31 changed files with 1104 additions and 1066 deletions
+6 -2
View File
@@ -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 -3
View File
@@ -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>
+1
View File
@@ -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 -55
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
+12 -11
View File
@@ -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. """
+2 -1
View File
@@ -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
+54
View File
@@ -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 -1
View File
@@ -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
@@ -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,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>
+1 -1
View File
@@ -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
+1
View File
@@ -3,3 +3,4 @@
from . import product
from . import project
from . import sale_order
from . import sale_order_line
+1 -283
View File
@@ -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
+1
View File
@@ -3,4 +3,5 @@
from . import product_template
from . import sale_order
from . import sale_order_line
from . import purchase_order
-240
View File
@@ -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
+5 -4
View File
@@ -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
-344
View File
@@ -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)
+350
View File
@@ -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)