19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -5,7 +5,5 @@ from . import res_company
from . import res_config_settings
from . import sale_order
from . import sale_order_line
from . import sale_order_option
from . import sale_order_template
from . import sale_order_template_line
from . import sale_order_template_option

View file

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo import _, fields, models
from odoo.exceptions import AccessError
class Digest(models.Model):
class DigestDigest(models.Model):
_inherit = 'digest.digest'
kpi_all_sale_total = fields.Boolean('All Sales')
@ -14,16 +13,16 @@ class Digest(models.Model):
def _compute_kpi_sale_total_value(self):
if not self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
for record in self:
start, end, company = record._get_kpi_compute_parameters()
all_channels_sales = self.env['sale.report']._read_group([
('date', '>=', start),
('date', '<', end),
('state', 'not in', ['draft', 'cancel', 'sent']),
('company_id', '=', company.id)], ['price_total'], ['company_id'])
record.kpi_all_sale_total_value = sum([channel_sale['price_total'] for channel_sale in all_channels_sales])
self._calculate_company_based_kpi(
'sale.report',
'kpi_all_sale_total_value',
date_field='date',
additional_domain=[('state', 'not in', ['draft', 'cancel', 'sent'])],
sum_field='price_total',
)
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_all_sale_total'] = 'sale.report_all_channels_sales_action&menu_id=%s' % self.env.ref('sale.sale_menu_root').id
res = super()._compute_kpis_actions(company, user)
res['kpi_all_sale_total'] = 'sale.report_all_channels_sales_action?menu_id=%s' % self.env.ref('sale.sale_menu_root').id
return res

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
@ -12,12 +11,6 @@ class ResConfigSettings(models.TransientModel):
company_so_template_id = fields.Many2one(
related="company_id.sale_order_template_id", string="Default Template", readonly=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
module_sale_quotation_builder = fields.Boolean("Quotation Builder")
@api.onchange('group_sale_order_template')
def _onchange_group_sale_order_template(self):
if not self.group_sale_order_template:
self.module_sale_quotation_builder = False
def set_values(self):
if not self.group_sale_order_template:

View file

@ -1,15 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from itertools import chain, starmap, zip_longest
from odoo import SUPERUSER_ID, api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import is_html_empty
from odoo.addons.sale.models.sale_order import READONLY_FIELD_STATES
class SaleOrder(models.Model):
_inherit = 'sale.order'
@ -19,13 +16,7 @@ class SaleOrder(models.Model):
string="Quotation Template",
compute='_compute_sale_order_template_id',
store=True, readonly=False, check_company=True, precompute=True,
states=READONLY_FIELD_STATES,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
sale_order_option_ids = fields.One2many(
comodel_name='sale.order.option', inverse_name='order_id',
string="Optional Products Lines",
states=READONLY_FIELD_STATES,
copy=True)
#=== COMPUTE METHODS ===#
@ -59,6 +50,13 @@ class SaleOrder(models.Model):
for order in self.filtered('sale_order_template_id'):
order.require_payment = order.sale_order_template_id.require_payment
@api.depends('sale_order_template_id')
def _compute_prepayment_percent(self):
super()._compute_prepayment_percent()
for order in self.filtered('sale_order_template_id'):
if order.require_payment:
order.prepayment_percent = order.sale_order_template_id.prepayment_percent
@api.depends('sale_order_template_id')
def _compute_validity_date(self):
super()._compute_validity_date()
@ -67,26 +65,18 @@ class SaleOrder(models.Model):
if validity_days > 0:
order.validity_date = fields.Date.context_today(order) + timedelta(validity_days)
#=== CONSTRAINT METHODS ===#
@api.constrains('company_id', 'sale_order_option_ids')
def _check_optional_product_company_id(self):
for order in self:
companies = order.sale_order_option_ids.product_id.company_id
if companies and companies != order.company_id:
bad_products = order.sale_order_option_ids.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id)
raise ValidationError(_(
"Your quotation contains products from company %(product_company)s whereas your quotation belongs to company %(quote_company)s. \n Please change the company of your quotation or remove the products from other companies (%(bad_products)s).",
product_company=', '.join(companies.mapped('display_name')),
quote_company=order.company_id.display_name,
bad_products=', '.join(bad_products.mapped('display_name')),
))
@api.depends('sale_order_template_id')
def _compute_journal_id(self):
super()._compute_journal_id()
for order in self.filtered('sale_order_template_id'):
order.journal_id = order.sale_order_template_id.journal_id
#=== ONCHANGE METHODS ===#
@api.onchange('company_id')
def _onchange_company_id(self):
"""Trigger quotation template recomputation on unsaved records company change"""
super()._onchange_company_id()
if self._origin.id:
return
self._compute_sale_order_template_id()
@ -111,14 +101,6 @@ class SaleOrder(models.Model):
self.order_line = order_lines_data
option_lines_data = [fields.Command.clear()]
option_lines_data += [
fields.Command.create(option._prepare_option_line_values())
for option in sale_order_template.sale_order_template_option_ids
]
self.sale_order_option_ids = option_lines_data
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Reload template for unsaved orders with unmodified lines & orders."""
@ -126,47 +108,33 @@ class SaleOrder(models.Model):
return
def line_eqv(line, t_line):
return line and t_line and (
line.product_id == t_line.product_id
and line.display_type == t_line.display_type
and line.product_uom == t_line.product_uom_id
and line.product_uom_qty == t_line.product_uom_qty
)
def option_eqv(option, t_option):
return option and t_option and all(
option[fname] == t_option[fname]
for fname in ['product_id', 'uom_id', 'quantity']
return line and t_line and all(
line[fname] == t_line[fname]
for fname in ['product_id', 'product_uom_id', 'product_uom_qty', 'display_type']
)
lines = self.order_line
options = self.sale_order_option_ids
t_lines = self.sale_order_template_id.sale_order_template_line_ids
t_options = self.sale_order_template_id.sale_order_template_option_ids
if all(chain(
starmap(line_eqv, zip_longest(lines, t_lines)),
starmap(option_eqv, zip_longest(options, t_options)),
)):
if all(starmap(line_eqv, zip_longest(lines, t_lines))):
self._onchange_sale_order_template_id()
#=== ACTION METHODS ===#
def _get_confirmation_template(self):
self.ensure_one()
return self.sale_order_template_id.mail_template_id or super()._get_confirmation_template()
def action_confirm(self):
res = super().action_confirm()
if self.env.su:
self = self.with_user(SUPERUSER_ID)
if self.env.context.get('send_email'):
# Mail already sent in super method
return res
# When an order is confirmed from backend (send_email=False), if the quotation template has
# a specified mail template, send it as it's probably meant to share additional information.
for order in self:
if order.sale_order_template_id and order.sale_order_template_id.mail_template_id:
order.sale_order_template_id.mail_template_id.send_mail(order.id)
if order.sale_order_template_id.mail_template_id:
order._send_order_notification_mail(order.sale_order_template_id.mail_template_id)
return res
def _recompute_prices(self):
super()._recompute_prices()
# Special case: we want to overwrite the existing discount on _recompute_prices call
# i.e. to make sure the discount is correctly reset
# if pricelist discount_policy is different than when the price was first computed.
self.sale_order_option_ids.discount = 0.0
self.sale_order_option_ids._compute_price_unit()
self.sale_order_option_ids._compute_discount()

View file

@ -1,29 +1,59 @@
# -*- 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"
_inherit = 'sale.order.line'
_description = "Sales Order Line"
sale_order_option_ids = fields.One2many('sale.order.option', 'line_id', 'Optional Products Lines')
# Section-related fields
is_optional = fields.Boolean(
string="Optional Line",
copy=True,
default=False,
) # Whether this section's lines are optional in the portal.
# === COMPUTE METHODS === #
@api.depends('product_id')
def _compute_name(self):
# Take the description on the order template if the product is present in it
super()._compute_name()
for line in self:
if line.product_id and line.order_id.sale_order_template_id:
if line.product_id and line.order_id.sale_order_template_id and line._use_template_name():
for template_line in line.order_id.sale_order_template_id.sale_order_template_line_ids:
if line.product_id == template_line.product_id:
if line.product_id == template_line.product_id and template_line.name:
# If a specific description was set on the template, use it
# Otherwise the description is handled by the super call
lang = line.order_id.partner_id.lang
line.name = template_line.with_context(lang=lang).name + line.with_context(lang=lang)._get_sale_order_line_multiline_description_variants()
break
def _compute_price_unit(self):
# Avoid recomputing the price with pricelist rules, use the initial price
# used in the optional product line.
optional_product_lines = self.filtered('sale_order_option_ids')
super(SaleOrderLine, self - optional_product_lines)._compute_price_unit()
def _use_template_name(self):
""" Allows overriding to avoid using the template lines descriptions for the sale order lines descriptions.
This is typically useful for 'configured' products, such as event_ticket or event_booth, where we need to have
specific configuration information inside description instead of the default values.
"""
self.ensure_one()
return True
# === TOOLING ===#
def _is_line_optional(self):
""" Returns whether the line is optional or not.
A line is optional if it is directly under an optional (sub)section, or under a subsection
which is itself under an optional section.
"""
self.ensure_one()
return (
self.parent_id.is_optional
or (
self.parent_id.display_type == 'line_subsection'
and self.parent_id.parent_id.is_optional
)
)
def _can_be_edited_on_portal(self):
return super()._can_be_edited_on_portal() and self._is_line_optional()

View file

@ -1,150 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SaleOrderOption(models.Model):
_name = 'sale.order.option'
_description = "Sale Options"
_order = 'sequence, id'
# FIXME ANVFE wtf is it not required ???
# TODO related to order.company_id and restrict product choice based on company
order_id = fields.Many2one('sale.order', 'Sales Order Reference', ondelete='cascade', index=True)
product_id = fields.Many2one(
comodel_name='product.product',
required=True,
domain=[('sale_ok', '=', True)])
line_id = fields.Many2one(
comodel_name='sale.order.line', ondelete='set null', copy=False)
sequence = fields.Integer(
string='Sequence', help="Gives the sequence order when displaying a list of optional products.")
name = fields.Text(
string="Description",
compute='_compute_name',
store=True, readonly=False,
required=True, precompute=True)
quantity = fields.Float(
string="Quantity",
required=True,
digits='Product Unit of Measure',
default=1)
uom_id = fields.Many2one(
comodel_name='uom.uom',
string="Unit of Measure",
compute='_compute_uom_id',
store=True, readonly=False,
required=True, precompute=True,
domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
price_unit = fields.Float(
string="Unit Price",
digits='Product Price',
compute='_compute_price_unit',
store=True, readonly=False,
required=True, precompute=True)
discount = fields.Float(
string="Discount (%)",
digits='Discount',
compute='_compute_discount',
store=True, readonly=False, precompute=True)
is_present = fields.Boolean(
string="Present on Quotation",
compute='_compute_is_present',
search='_search_is_present',
help="This field will be checked if the option line's product is "
"already present in the quotation.")
#=== COMPUTE METHODS ===#
@api.depends('product_id')
def _compute_name(self):
for option in self:
if not option.product_id:
continue
product_lang = option.product_id.with_context(lang=option.order_id.partner_id.lang)
option.name = product_lang.get_product_multiline_description_sale()
@api.depends('product_id')
def _compute_uom_id(self):
for option in self:
if not option.product_id or option.uom_id:
continue
option.uom_id = option.product_id.uom_id
@api.depends('product_id', 'uom_id', 'quantity')
def _compute_price_unit(self):
for option in self:
if not option.product_id:
continue
# To compute the price_unit a so line is created in cache
values = option._get_values_to_add_to_order()
new_sol = self.env['sale.order.line'].new(values)
new_sol._compute_price_unit()
option.price_unit = new_sol.price_unit
# Drop the temporary record from the cache
new_sol.invalidate_recordset(flush=False)
@api.depends('product_id', 'uom_id', 'quantity')
def _compute_discount(self):
for option in self:
if not option.product_id:
continue
# To compute the discount a so line is created in cache
values = option._get_values_to_add_to_order()
new_sol = self.env['sale.order.line'].new(values)
new_sol._compute_discount()
option.discount = new_sol.discount
# Drop the temporary record from the cache
new_sol.invalidate_recordset(flush=False)
def _get_values_to_add_to_order(self):
self.ensure_one()
return {
'order_id': self.order_id.id,
'price_unit': self.price_unit,
'name': self.name,
'product_id': self.product_id.id,
'product_uom_qty': self.quantity,
'product_uom': self.uom_id.id,
'discount': self.discount,
}
@api.depends('line_id', 'order_id.order_line', 'product_id')
def _compute_is_present(self):
# NOTE: this field cannot be stored as the line_id is usually removed
# through cascade deletion, which means the compute would be false
for option in self:
option.is_present = bool(option.order_id.order_line.filtered(lambda l: l.product_id == option.product_id))
def _search_is_present(self, operator, value):
if (operator, value) in [('=', True), ('!=', False)]:
return [('line_id', '=', False)]
return [('line_id', '!=', False)]
#=== ACTION METHODS ===#
def button_add_to_order(self):
self.add_option_to_order()
def add_option_to_order(self):
self.ensure_one()
sale_order = self.order_id
if sale_order.state not in ['draft', 'sent']:
raise UserError(_('You cannot add options to a confirmed order.'))
values = self._get_values_to_add_to_order()
order_line = self.env['sale.order.line'].create(values)
self.write({'line_id': order_line.id})
if sale_order:
sale_order.add_option_to_order_with_taxcloud()

View file

@ -1,21 +1,23 @@
# -*- 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, fields, models
from odoo.exceptions import ValidationError
from odoo.fields import Command
class SaleOrderTemplate(models.Model):
_name = "sale.order.template"
_name = 'sale.order.template'
_description = "Quotation Template"
_order = 'sequence, id'
active = fields.Boolean(
default=True,
help="If unchecked, it will allow you to hide the quotation template without removing it.")
company_id = fields.Many2one(comodel_name='res.company')
company_id = fields.Many2one(comodel_name='res.company', default=lambda self: self.env.company)
name = fields.Char(string="Quotation Template", required=True)
note = fields.Html(string="Terms and conditions", translate=True)
sequence = fields.Integer(default=10)
mail_template_id = fields.Many2one(
comodel_name='mail.template',
@ -36,15 +38,21 @@ class SaleOrderTemplate(models.Model):
compute='_compute_require_payment',
store=True, readonly=False,
help="Request an online payment to the customer in order to confirm orders automatically.")
prepayment_percent = fields.Float(
string="Prepayment percentage",
compute="_compute_prepayment_percent",
store=True, readonly=False,
help="The percentage of the amount needed to be paid to confirm quotations.")
sale_order_template_line_ids = fields.One2many(
comodel_name='sale.order.template.line', inverse_name='sale_order_template_id',
string="Lines",
copy=True)
sale_order_template_option_ids = fields.One2many(
comodel_name='sale.order.template.option', inverse_name='sale_order_template_id',
string="Optional Products",
copy=True)
journal_id = fields.Many2one(
'account.journal', string="Invoicing Journal",
domain=[('type', '=', 'sale')], company_dependent=True, check_company=True,
help="If set, SO with this template will invoice in this journal; "
"otherwise the sales journal with the lowest sequence is used.")
#=== COMPUTE METHODS ===#
@ -58,21 +66,69 @@ class SaleOrderTemplate(models.Model):
for order in self:
order.require_payment = (order.company_id or order.env.company).portal_confirmation_pay
@api.depends('company_id', 'require_payment')
def _compute_prepayment_percent(self):
for template in self:
template.prepayment_percent = (
template.company_id or template.env.company
).prepayment_percent
#=== ONCHANGE METHODS ===#
@api.onchange('prepayment_percent')
def _onchange_prepayment_percent(self):
for template in self:
if not template.prepayment_percent:
template.require_payment = False
#=== CONSTRAINT METHODS ===#
@api.constrains('company_id', 'sale_order_template_line_ids', 'sale_order_template_option_ids')
@api.constrains('company_id', 'sale_order_template_line_ids')
def _check_company_id(self):
for template in self:
companies = template.mapped('sale_order_template_line_ids.product_id.company_id') | template.mapped('sale_order_template_option_ids.product_id.company_id')
if len(companies) > 1:
raise ValidationError(_("Your template cannot contain products from multiple companies."))
elif companies and companies != template.company_id:
restricted_products = template.sale_order_template_line_ids.product_id.filtered(
'company_id'
)
if not restricted_products:
continue
if not template.company_id:
raise ValidationError(_(
"Your template contains products from company %(product_company)s whereas your template belongs to company %(template_company)s. \n Please change the company of your template or remove the products from other companies.",
product_company=', '.join(companies.mapped('display_name')),
"Your template cannot contain products from specific companies if it's shared"
" between companies. Please restrict the template access, or remove those"
" products."
))
authorized_products = restricted_products.filtered_domain(
self.env['product.product']._check_company_domain(template.company_id)
)
if unauthorized_products := restricted_products - authorized_products:
unaccessible_companies = unauthorized_products.company_id
if len(unaccessible_companies) > 1:
raise ValidationError(_(
"Your template belongs to company %(template_company)s but contains"
" products from other companies (%(product_company)s) that are not"
" accessible to %(template_company)s.\nPlease change the company of your"
" template or remove the products from other companies.",
product_company=', '.join(unaccessible_companies.mapped('display_name')),
template_company=template.company_id.display_name,
))
raise ValidationError(_(
"Your template belongs to company %(template_company)s but contains"
" products from company (%(product_company)s) that are not"
" accessible to %(template_company)s.\nPlease change the company of your"
" template or remove the products from other companies.",
product_company=unaccessible_companies.display_name,
template_company=template.company_id.display_name,
))
@api.constrains('prepayment_percent')
def _check_prepayment_percent(self):
for template in self:
if template.require_payment and not (0 < template.prepayment_percent <= 1.0):
raise ValidationError(_("Prepayment percentage must be a valid percentage."))
#=== CRUD METHODS ===#
@api.model_create_multi
@ -90,11 +146,58 @@ class SaleOrderTemplate(models.Model):
return result
def _update_product_translations(self):
languages = self.env['res.lang'].search([('active', '=', 'true')])
languages = self.env['res.lang'].search([('active', '=', True)])
for lang in languages:
for line in self.sale_order_template_line_ids:
if line.name == line.product_id.get_product_multiline_description_sale():
line.with_context(lang=lang.code).name = line.product_id.with_context(lang=lang.code).get_product_multiline_description_sale()
for option in self.sale_order_template_option_ids:
if option.name == option.product_id.get_product_multiline_description_sale():
option.with_context(lang=lang.code).name = option.product_id.with_context(lang=lang.code).get_product_multiline_description_sale()
@api.model
def _demo_configure_template(self):
demo_template = self.env.ref(
'sale_management.sale_order_template_1', raise_if_not_found=False
)
if not demo_template or demo_template.sale_order_template_line_ids:
# Skip if template not found, or already configured
return
acoustic_bloc_screen_product = self.env.ref(
'product.product_template_acoustic_bloc_screens'
).product_variant_id
chair_protection_product = self.env.ref(
'sale.product_product_1_product_template'
).product_variant_id
demo_template.sale_order_template_line_ids = [
Command.create({
'product_id': self.env.ref('product.consu_delivery_02').id,
}),
Command.create({
'product_id': self.env.ref('product.product_delivery_01').id,
'product_uom_qty': 8,
}),
Command.create({
'product_id': acoustic_bloc_screen_product.id,
}),
Command.create({
'product_id': chair_protection_product.id,
'product_uom_qty': 8,
}),
Command.create({
'name': self.env._("Optional Products Section"),
'display_type': 'line_section',
'is_optional': True,
'product_uom_qty': 0,
}),
Command.create({
'product_id': self.env.ref('product.product_product_16').id,
'product_uom_qty': 0,
}),
Command.create({
'product_id': self.env.ref('product.product_product_7').id,
'product_uom_qty': 0,
}),
Command.create({
'product_id': self.env.ref('product.product_product_12').id,
'product_uom_qty': 0,
}),
]

View file

@ -1,24 +1,22 @@
# -*- 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, fields, models
from odoo.exceptions import UserError
class SaleOrderTemplateLine(models.Model):
_name = "sale.order.template.line"
_name = 'sale.order.template.line'
_description = "Quotation Template Line"
_order = 'sale_order_template_id, sequence, id'
_sql_constraints = [
('accountable_product_id_required',
"CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom_id IS NOT NULL))",
"Missing required product and UoM on accountable sale quote line."),
('non_accountable_fields_null',
"CHECK(display_type IS NULL OR (product_id IS NULL AND product_uom_qty = 0 AND product_uom_id IS NULL))",
"Forbidden product, quantity and UoM on non-accountable sale quote line"),
]
_accountable_product_id_required = models.Constraint(
'CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom_id IS NOT NULL))',
'Missing required product and UoM on accountable sale quote line.',
)
_non_accountable_fields_null = models.Constraint(
'CHECK(display_type IS NULL OR (product_id IS NULL AND product_uom_qty = 0 AND product_uom_id IS NULL))',
'Forbidden product, quantity and UoM on non-accountable sale quote line',
)
sale_order_template_id = fields.Many2one(
comodel_name='sale.order.template',
@ -36,46 +34,76 @@ class SaleOrderTemplateLine(models.Model):
product_id = fields.Many2one(
comodel_name='product.product',
check_company=True,
domain="[('sale_ok', '=', True), ('company_id', 'in', [company_id, False])]")
domain=lambda self: self._product_id_domain())
name = fields.Text(
string="Description",
compute='_compute_name',
store=True, readonly=False, precompute=True,
required=True,
translate=True)
translate=True,
)
allowed_uom_ids = fields.Many2many('uom.uom', compute='_compute_allowed_uom_ids')
product_uom_id = fields.Many2one(
comodel_name='uom.uom',
string="Unit of Measure",
string="Unit",
domain="[('id', 'in', allowed_uom_ids)]",
compute='_compute_product_uom_id',
store=True, readonly=False, precompute=True,
domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
store=True, readonly=False, precompute=True)
product_uom_qty = fields.Float(
string='Quantity',
required=True,
digits='Product Unit of Measure',
digits='Product Unit',
default=1)
display_type = fields.Selection([
('line_section', "Section"),
('line_subsection', "Subsection"),
('line_note', "Note")], default=False)
# Section-related fields
parent_id = fields.Many2one(
string="Parent Section Line",
comodel_name='sale.order.template.line',
compute='_compute_parent_id',
)
is_optional = fields.Boolean(
string="Optional Line",
copy=True,
default=False,
)
#=== COMPUTE METHODS ===#
@api.depends('product_id')
def _compute_name(self):
@api.depends('product_id', 'product_id.uom_id', 'product_id.uom_ids')
def _compute_allowed_uom_ids(self):
for option in self:
if not option.product_id:
continue
option.name = option.product_id.get_product_multiline_description_sale()
option.allowed_uom_ids = option.product_id.uom_id | option.product_id.uom_ids
@api.depends('product_id')
def _compute_product_uom_id(self):
for option in self:
option.product_uom_id = option.product_id.uom_id
def _compute_parent_id(self):
option_lines = set(self)
for template, lines in self.grouped('sale_order_template_id').items():
if not template:
lines.parent_id = False
continue
last_section = False
last_sub = False
for line in template.sale_order_template_line_ids.sorted('sequence'):
if line.display_type == 'line_section':
last_section = line
if line in option_lines:
line.parent_id = False
last_sub = False
elif line.display_type == 'line_subsection':
if line in option_lines:
line.parent_id = last_section
last_sub = line
elif line in option_lines:
line.parent_id = last_sub or last_section
#=== CRUD METHODS ===#
@api.model_create_multi
@ -85,13 +113,18 @@ class SaleOrderTemplateLine(models.Model):
vals.update(product_id=False, product_uom_qty=0, product_uom_id=False)
return super().create(vals_list)
def write(self, values):
if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')):
def write(self, vals):
if 'display_type' in vals and self.filtered(lambda line: line.display_type != vals.get('display_type')):
raise UserError(_("You cannot change the type of a sale quote line. Instead you should delete the current line and create a new line of the proper type."))
return super().write(values)
return super().write(vals)
#=== BUSINESS METHODS ===#
@api.model
def _product_id_domain(self):
""" Returns the domain of the products that can be added to the template. """
return [('sale_ok', '=', True), ('type', '!=', 'combo')]
def _prepare_order_line_values(self):
""" Give the values to create the corresponding order line.
@ -99,10 +132,14 @@ class SaleOrderTemplateLine(models.Model):
:rtype: dict
"""
self.ensure_one()
return {
vals = {
'display_type': self.display_type,
'name': self.name,
'product_id': self.product_id.id,
'product_uom_qty': self.product_uom_qty,
'product_uom': self.product_uom_id.id,
'product_uom_id': self.product_uom_id.id,
'is_optional': self.is_optional,
'sequence': self.sequence,
}
if self.name:
vals['name'] = self.name
return vals

View file

@ -1,74 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class SaleOrderTemplateOption(models.Model):
_name = "sale.order.template.option"
_description = "Quotation Template Option"
_check_company_auto = True
sale_order_template_id = fields.Many2one(
comodel_name='sale.order.template',
string="Quotation Template Reference",
index=True, required=True,
ondelete='cascade')
company_id = fields.Many2one(
related='sale_order_template_id.company_id', store=True, index=True)
product_id = fields.Many2one(
comodel_name='product.product',
required=True, check_company=True,
domain="[('sale_ok', '=', True), ('company_id', 'in', [company_id, False])]")
name = fields.Text(
string="Description",
compute='_compute_name',
store=True, readonly=False, precompute=True,
required=True, translate=True)
uom_id = fields.Many2one(
comodel_name='uom.uom',
string="Unit of Measure",
compute='_compute_uom_id',
store=True, readonly=False,
required=True, precompute=True,
domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
quantity = fields.Float(
string="Quantity",
required=True,
digits='Product Unit of Measure',
default=1)
#=== COMPUTE METHODS ===#
@api.depends('product_id')
def _compute_name(self):
for option in self:
if not option.product_id:
continue
option.name = option.product_id.get_product_multiline_description_sale()
@api.depends('product_id')
def _compute_uom_id(self):
for option in self:
option.uom_id = option.product_id.uom_id
#=== BUSINESS METHODS ===#
def _prepare_option_line_values(self):
""" Give the values to create the corresponding option line.
:return: `sale.order.option` create values
:rtype: dict
"""
self.ensure_one()
return {
'name': self.name,
'product_id': self.product_id.id,
'quantity': self.quantity,
'uom_id': self.uom_id.id,
}