Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import digest
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

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
_inherit = 'digest.digest'
kpi_all_sale_total = fields.Boolean('All Sales')
kpi_all_sale_total_value = fields.Monetary(compute='_compute_kpi_sale_total_value')
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])
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
return res

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResCompany(models.Model):
_inherit = "res.company"
_check_company_auto = True
sale_order_template_id = fields.Many2one(
"sale.order.template", string="Default Sale Template",
domain="['|', ('company_id', '=', False), ('company_id', '=', id)]",
check_company=True,
)

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
group_sale_order_template = fields.Boolean(
"Quotation Templates", implied_group='sale_management.group_sale_order_template')
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:
if self.company_so_template_id:
self.company_so_template_id = False
companies = self.env['res.company'].sudo().search([
('sale_order_template_id', '!=', False)
])
if companies:
companies.sale_order_template_id = False
super().set_values()

View file

@ -0,0 +1,172 @@
# -*- 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.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'
sale_order_template_id = fields.Many2one(
comodel_name='sale.order.template',
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 ===#
# Do not make it depend on `company_id` field
# It is triggered manually by the _onchange_company_id below iff the SO has not been saved.
def _compute_sale_order_template_id(self):
for order in self:
company_template = order.company_id.sale_order_template_id
if company_template and order.sale_order_template_id != company_template:
if 'website_id' in self._fields and order.website_id:
# don't apply quotation template for order created via eCommerce
continue
order.sale_order_template_id = order.company_id.sale_order_template_id.id
@api.depends('partner_id', 'sale_order_template_id')
def _compute_note(self):
super()._compute_note()
for order in self.filtered('sale_order_template_id'):
template = order.sale_order_template_id.with_context(lang=order.partner_id.lang)
order.note = template.note if not is_html_empty(template.note) else order.note
@api.depends('sale_order_template_id')
def _compute_require_signature(self):
super()._compute_require_signature()
for order in self.filtered('sale_order_template_id'):
order.require_signature = order.sale_order_template_id.require_signature
@api.depends('sale_order_template_id')
def _compute_require_payment(self):
super()._compute_require_payment()
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_validity_date(self):
super()._compute_validity_date()
for order in self.filtered('sale_order_template_id'):
validity_days = order.sale_order_template_id.number_of_days
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')),
))
#=== ONCHANGE METHODS ===#
@api.onchange('company_id')
def _onchange_company_id(self):
"""Trigger quotation template recomputation on unsaved records company change"""
if self._origin.id:
return
self._compute_sale_order_template_id()
@api.onchange('sale_order_template_id')
def _onchange_sale_order_template_id(self):
if not self.sale_order_template_id:
return
sale_order_template = self.sale_order_template_id.with_context(lang=self.partner_id.lang)
order_lines_data = [fields.Command.clear()]
order_lines_data += [
fields.Command.create(line._prepare_order_line_values())
for line in sale_order_template.sale_order_template_line_ids
]
# set first line to sequence -99, so a resequence on first page doesn't cause following page
# lines (that all have sequence 10 by default) to get mixed in the first page
if len(order_lines_data) >= 2:
order_lines_data[1][2]['sequence'] = -99
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."""
if self._origin or not self.sale_order_template_id:
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']
)
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)),
)):
self._onchange_sale_order_template_id()
#=== ACTION METHODS ===#
def action_confirm(self):
res = super().action_confirm()
if self.env.su:
self = self.with_user(SUPERUSER_ID)
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)
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

@ -0,0 +1,29 @@
# -*- 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"
_description = "Sales Order Line"
sale_order_option_ids = fields.One2many('sale.order.option', 'line_id', 'Optional Products Lines')
@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:
for template_line in line.order_id.sale_order_template_id.sale_order_template_line_ids:
if line.product_id == template_line.product_id:
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()

View file

@ -0,0 +1,150 @@
# -*- 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

@ -0,0 +1,100 @@
# -*- 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 ValidationError
class SaleOrderTemplate(models.Model):
_name = "sale.order.template"
_description = "Quotation Template"
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')
name = fields.Char(string="Quotation Template", required=True)
note = fields.Html(string="Terms and conditions", translate=True)
mail_template_id = fields.Many2one(
comodel_name='mail.template',
string="Confirmation Mail",
domain=[('model', '=', 'sale.order')],
help="This e-mail template will be sent on confirmation. Leave empty to send nothing.")
number_of_days = fields.Integer(
string="Quotation Duration",
help="Number of days for the validity date computation of the quotation")
require_signature = fields.Boolean(
string="Online Signature",
compute='_compute_require_signature',
store=True, readonly=False,
help="Request a online signature to the customer in order to confirm orders automatically.")
require_payment = fields.Boolean(
string="Online Payment",
compute='_compute_require_payment',
store=True, readonly=False,
help="Request an online payment to the customer in order to confirm orders automatically.")
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)
#=== COMPUTE METHODS ===#
@api.depends('company_id')
def _compute_require_signature(self):
for order in self:
order.require_signature = (order.company_id or order.env.company).portal_confirmation_sign
@api.depends('company_id')
def _compute_require_payment(self):
for order in self:
order.require_payment = (order.company_id or order.env.company).portal_confirmation_pay
#=== CONSTRAINT METHODS ===#
@api.constrains('company_id', 'sale_order_template_line_ids', 'sale_order_template_option_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:
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')),
template_company=template.company_id.display_name,
))
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
records._update_product_translations()
return records
def write(self, vals):
if 'active' in vals and not vals.get('active'):
companies = self.env['res.company'].sudo().search([('sale_order_template_id', 'in', self.ids)])
companies.sale_order_template_id = None
result = super().write(vals)
self._update_product_translations()
return result
def _update_product_translations(self):
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()

View file

@ -0,0 +1,108 @@
# -*- 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 SaleOrderTemplateLine(models.Model):
_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"),
]
sale_order_template_id = fields.Many2one(
comodel_name='sale.order.template',
string='Quotation Template Reference',
index=True, required=True,
ondelete='cascade')
sequence = fields.Integer(
string="Sequence",
help="Gives the sequence order when displaying a list of sale quote lines.",
default=10)
company_id = fields.Many2one(
related='sale_order_template_id.company_id', store=True, index=True)
product_id = fields.Many2one(
comodel_name='product.product',
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)
product_uom_id = fields.Many2one(
comodel_name='uom.uom',
string="Unit of Measure",
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')
product_uom_qty = fields.Float(
string='Quantity',
required=True,
digits='Product Unit of Measure',
default=1)
display_type = fields.Selection([
('line_section', "Section"),
('line_note', "Note")], default=False)
#=== 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_product_uom_id(self):
for option in self:
option.product_uom_id = option.product_id.uom_id
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('display_type', self.default_get(['display_type'])['display_type']):
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')):
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)
#=== BUSINESS METHODS ===#
def _prepare_order_line_values(self):
""" Give the values to create the corresponding order line.
:return: `sale.order.line` create values
:rtype: dict
"""
self.ensure_one()
return {
'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,
}

View file

@ -0,0 +1,74 @@
# -*- 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,
}