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

@ -1,16 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import analytic
from . import account_move
from . import account_move_line
from . import chart_template
from . import crm_team
from . import ir_actions_report
from . import ir_config_parameter
from . import payment_provider
from . import payment_transaction
from . import product_document
from . import product_pricelist_item
from . import product_product
from . import product_template
from . import res_company
from . import res_config_settings
from . import res_partner
from . import sale_order
from . import sale_order_line

View file

@ -1,9 +1,6 @@
# -*- 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
from odoo.tools import groupby
from odoo import _, api, fields, models
from odoo.tools import groupby, OrderedSet
class AccountMove(models.Model):
@ -20,7 +17,11 @@ class AccountMove(models.Model):
campaign_id = fields.Many2one(ondelete='set null')
medium_id = fields.Many2one(ondelete='set null')
source_id = fields.Many2one(ondelete='set null')
sale_order_count = fields.Integer(compute="_compute_origin_so_count", string='Sale Order Count')
sale_order_count = fields.Integer(compute="_compute_origin_so_count", string='Sale Order Count', compute_sudo=True)
sale_warning_text = fields.Text(
"Sale Warning",
help="Internal warning for the partner or the products as set by the user.",
compute="_compute_sale_warning_text")
def unlink(self):
downpayment_lines = self.mapped('line_ids.sale_line_ids').filtered(lambda line: line.is_downpayment and line.invoice_lines <= self.mapped('line_ids'))
@ -31,17 +32,13 @@ class AccountMove(models.Model):
@api.depends('invoice_user_id')
def _compute_team_id(self):
applicable_moves = self.filtered(
lambda move:
move.is_sale_document(include_receipts=True)
)
sale_moves = self.filtered(lambda move: move.is_sale_document(include_receipts=True))
for ((user_id, company_id), moves) in groupby(
applicable_moves,
sale_moves,
key=lambda m: (m.invoice_user_id.id, m.company_id.id)
):
self.env['account.move'].concat(*moves).team_id = self.env['crm.team'].with_context(
allowed_company_ids=[company_id]
allowed_company_ids=[company_id],
)._get_default_team_id(
user_id=user_id,
)
@ -51,6 +48,26 @@ class AccountMove(models.Model):
for move in self:
move.sale_order_count = len(move.line_ids.sale_line_ids.order_id)
@api.depends('partner_id.name', 'partner_id.sale_warn_msg', 'invoice_line_ids.product_id.sale_line_warn_msg', 'invoice_line_ids.product_id.display_name')
def _compute_sale_warning_text(self):
if not self.env.user.has_group('sale.group_warning_sale'):
self.sale_warning_text = ''
return
for move in self:
if move.move_type != 'out_invoice':
move.sale_warning_text = ''
continue
warnings = OrderedSet()
if partner_msg := move.partner_id.sale_warn_msg:
warnings.add((move.partner_id.name or move.partner_id.display_name) + ' - ' + partner_msg)
if partner_parent_msg := move.partner_id.parent_id.sale_warn_msg:
parent = move.partner_id.parent_id
warnings.add((parent.name or parent.display_name) + ' - ' + partner_parent_msg)
for product in move.invoice_line_ids.product_id:
if product_msg := product.sale_line_warn_msg:
warnings.add(product.display_name + ' - ' + product_msg)
move.sale_warning_text = '\n'.join(warnings)
def _reverse_moves(self, default_values_list=None, cancel=False):
# OVERRIDE
if not default_values_list:
@ -64,25 +81,19 @@ class AccountMove(models.Model):
return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel)
def action_post(self):
#inherit of the function from account.move to validate a new tax and the priceunit of a downpayment
# inherit of the function from account.move to validate a new tax and the priceunit of a downpayment
res = super(AccountMove, self).action_post()
down_payment_lines = self.line_ids.filtered('is_downpayment')
for line in down_payment_lines:
if not line.sale_line_ids.display_type:
line.sale_line_ids._compute_name()
downpayment_lines = self.line_ids.sale_line_ids.filtered(lambda l: l.is_downpayment and not l.display_type)
# We cannot change lines content on locked SO, changes on invoices are not forwarded to the SO if the SO is locked
dp_lines = self.line_ids.sale_line_ids.filtered(lambda l: l.is_downpayment and not l.display_type)
dp_lines._compute_name() # Update the description of DP lines (Draft -> Posted)
downpayment_lines = dp_lines.filtered(lambda sol: not sol.order_id.locked)
other_so_lines = downpayment_lines.order_id.order_line - downpayment_lines
real_invoices = set(other_so_lines.invoice_lines.move_id)
for dpl in downpayment_lines:
try:
dpl.price_unit = dpl._get_downpayment_line_price_unit(real_invoices)
dpl.tax_id = dpl.invoice_lines.tax_ids
except UserError:
# a UserError here means the SO was locked, which prevents changing the taxes
# just ignore the error - this is a nice to have feature and should not be blocking
pass
for so_dpl in downpayment_lines:
so_dpl.price_unit = so_dpl._get_downpayment_line_price_unit(real_invoices)
so_dpl.tax_ids = so_dpl.invoice_lines.tax_ids
return res
def button_draft(self):
@ -108,8 +119,8 @@ class AccountMove(models.Model):
posted = super()._post(soft)
for invoice in posted.filtered(lambda move: move.is_invoice()):
payments = invoice.mapped('transaction_ids.payment_id').filtered(lambda x: x.state == 'posted')
move_lines = payments.line_ids.filtered(lambda line: line.account_type in ('asset_receivable', 'liability_payable') and not line.reconciled)
payments = invoice.mapped('transaction_ids.payment_id').filtered(lambda x: x.state == 'in_process')
move_lines = payments.move_id.line_ids.filtered(lambda line: line.account_type in ('asset_receivable', 'liability_payable') and not line.reconciled)
for line in move_lines:
invoice.js_assign_outstanding_line(line.id)
return posted
@ -154,3 +165,42 @@ class AccountMove(models.Model):
# OVERRIDE
self.ensure_one()
return self.line_ids.sale_line_ids and all(sale_line.is_downpayment for sale_line in self.line_ids.sale_line_ids) or False
def _get_sale_order_invoiced_amount(self, order):
"""
Consider all lines on any invoice in self that stem from the sales order `order`. (All those invoices belong to order.company_id)
This function returns the sum of the totals of all those lines.
Note that this amount may be bigger than `order.amount_total`.
"""
order_amount = 0
for invoice in self:
prices = sum(invoice.line_ids.filtered(
lambda x: x.display_type not in ('line_note', 'line_section') and order in x.sale_line_ids.order_id
).mapped('price_total'))
order_amount += invoice.currency_id._convert(
prices * -invoice.direction_sign,
order.currency_id,
invoice.company_id,
invoice.date,
)
return order_amount
def _get_partner_credit_warning_exclude_amount(self):
# EXTENDS module 'account'
# Consider the warning on a draft invoice created from a sales order.
# After confirming the invoice the (partial) amount (on the invoice)
# stemming from sales orders will be substracted from the credit_to_invoice.
# This will reduce the total credit of the partner.
# The computation should reflect the change of credit_to_invoice from 'res.partner'.
# (see _compute_credit_to_invoice and _compute_amount_to_invoice from 'sale.order' )
exclude_amount = super()._get_partner_credit_warning_exclude_amount()
for order in self.line_ids.sale_line_ids.order_id:
order_amount = min(self._get_sale_order_invoiced_amount(order), order.amount_to_invoice)
order_amount_company = order.currency_id._convert(
max(order_amount, 0),
self.company_id.currency_id,
self.company_id,
fields.Date.context_today(self)
)
exclude_amount += order_amount_company
return exclude_amount

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, _
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_compare, float_is_zero
@ -15,17 +14,42 @@ class AccountMoveLine(models.Model):
'sale_order_line_invoice_rel',
'invoice_line_id', 'order_line_id',
string='Sales Order Lines', readonly=True, copy=False)
sale_line_warn_msg = fields.Text(compute='_compute_sale_line_warn_msg')
@api.depends('product_id.sale_line_warn_msg')
def _compute_sale_line_warn_msg(self):
has_warning_group = self.env.user.has_group('sale.group_warning_sale')
for line in self:
line.sale_line_warn_msg = line.product_id.sale_line_warn_msg if has_warning_group else ""
@api.depends('balance')
def _compute_is_storno(self):
# EXTENDS 'account'
super()._compute_is_storno()
for line in self:
if line.is_downpayment:
# Normal downpayments have a negative balance (credit on customer invoice)
# Positive balance indicate reversal lines for previous downpayments,
# which should be treated as storno line if storno accounting is enabled.
line.is_storno = line.company_id.account_storno and line.balance > 0.0
def _copy_data_extend_business_fields(self, values):
# OVERRIDE to copy the 'sale_line_ids' field as well.
super(AccountMoveLine, self)._copy_data_extend_business_fields(values)
super()._copy_data_extend_business_fields(values)
values['sale_line_ids'] = [(6, None, self.sale_line_ids.ids)]
def _related_analytic_distribution(self):
# EXTENDS 'account'
vals = super()._related_analytic_distribution()
if self.sale_line_ids and not self.analytic_distribution:
vals |= self.sale_line_ids[0].analytic_distribution or {}
return vals
def _prepare_analytic_lines(self):
""" Note: This method is called only on the move.line that having an analytic distribution, and
so that should create analytic entries.
"""
values_list = super(AccountMoveLine, self)._prepare_analytic_lines()
values_list = super()._prepare_analytic_lines()
# filter the move lines that can be reinvoiced: a cost (negative amount) analytic line without SO line but with a product can be reinvoiced
move_to_reinvoice = self.env['account.move.line']
@ -53,7 +77,7 @@ class AccountMoveLine(models.Model):
self.ensure_one()
if self.sale_line_ids:
return False
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit')
return float_compare(self.credit or 0.0, self.debit or 0.0, precision_digits=uom_precision_digits) != 1 and self.product_id.expense_policy not in [False, 'no']
def _sale_create_reinvoice_sale_line(self):
@ -76,15 +100,23 @@ class AccountMoveLine(models.Model):
continue
# raise if the sale order is not currently open
if sale_order.state != 'sale':
message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.')
messages = {
'draft': message_unconfirmed,
'sent': message_unconfirmed,
'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'),
'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'),
}
raise UserError(messages[sale_order.state] % (sale_order.name, sale_order.analytic_account_id.name))
if sale_order.state in ('draft', 'sent'):
raise UserError(_(
"The Sales Order %(order)s to be reinvoiced must be validated before registering expenses.",
order=sale_order.name,
))
elif sale_order.state == 'cancel':
raise UserError(_(
"The Sales Order %(order)s to be reinvoiced is cancelled."
" You cannot register an expense on a cancelled Sales Order.",
order=sale_order.name,
))
elif sale_order.locked:
raise UserError(_(
"The Sales Order %(order)s to be reinvoiced is currently locked."
" You cannot register an expense on a locked Sales Order.",
order=sale_order.name,
))
price = move_line._sale_get_invoice_price(sale_order)
@ -138,20 +170,7 @@ class AccountMoveLine(models.Model):
""" Get the mapping of move.line with the sale.order record on which its analytic entries should be reinvoiced
:return a dict where key is the move line id, and value is sale.order record (or None).
"""
mapping = {}
for move_line in self:
if move_line.analytic_distribution:
distribution_json = move_line.analytic_distribution
sale_order = self.env['sale.order'].search([('analytic_account_id', 'in', list(int(account_id) for account_id in distribution_json.keys())),
('state', '=', 'sale')], order='create_date ASC', limit=1)
if sale_order:
mapping[move_line.id] = sale_order
else:
sale_order = self.env['sale.order'].search([('analytic_account_id', 'in', list(int(account_id) for account_id in distribution_json.keys()))], order='create_date ASC', limit=1)
mapping[move_line.id] = sale_order
# map of AAL index with the SO on which it needs to be reinvoiced. Maybe be None if no SO found
return mapping
return {}
def _sale_prepare_sale_line_values(self, order, price):
""" Generate the sale.line creation value from the current move line """
@ -160,7 +179,7 @@ class AccountMoveLine(models.Model):
last_sequence = last_so_line.sequence + 1 if last_so_line else 100
fpos = order.fiscal_position_id or order.fiscal_position_id._get_fiscal_position(order.partner_id)
product_taxes = self.product_id.taxes_id.filtered(lambda tax: tax.company_id == order.company_id)
product_taxes = self.product_id.taxes_id._filter_taxes_by_company(order.company_id)
taxes = fpos.map_tax(product_taxes)
return {
@ -168,12 +187,13 @@ class AccountMoveLine(models.Model):
'name': self.name,
'sequence': last_sequence,
'price_unit': price,
'tax_id': [x.id for x in taxes],
'tax_ids': [x.id for x in taxes],
'discount': 0.0,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': 0.0,
'product_uom_id': self.product_uom_id.id,
'product_uom_qty': self.quantity,
'is_expense': True,
'analytic_distribution': self.analytic_distribution,
}
def _sale_get_invoice_price(self, order):
@ -193,7 +213,7 @@ class AccountMoveLine(models.Model):
date=order.date_order,
)
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit')
if float_is_zero(unit_amount, precision_digits=uom_precision_digits):
return 0.0

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
@ -7,13 +6,7 @@ from odoo import fields, models
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
def _default_sale_line_domain(self):
""" This is only used for delivered quantity of SO line based on analytic line, and timesheet
(see sale_timesheet). This can be override to allow further customization.
"""
return [('qty_delivered_method', '=', 'analytic')]
so_line = fields.Many2one('sale.order.line', string='Sales Order Item', domain=lambda self: self._default_sale_line_domain())
so_line = fields.Many2one('sale.order.line', string='Sales Order Item', domain=[('qty_delivered_method', '=', 'analytic')], index='btree_not_null')
class AccountAnalyticApplicability(models.Model):

View file

@ -0,0 +1,10 @@
from odoo import models
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
def _get_property_accounts(self, additional_properties):
property_accounts = super()._get_property_accounts(additional_properties)
property_accounts['downpayment_account_id'] = 'res.company'
return property_accounts

View file

@ -1,14 +1,13 @@
# -*- 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
from odoo.tools import SQL
class CrmTeam(models.Model):
_inherit = 'crm.team'
use_quotations = fields.Boolean(string='Quotations', help="Check this box if you send quotations to your customers rather than confirming orders straight away.")
invoiced = fields.Float(
compute='_compute_invoiced',
string='Invoiced This Month', readonly=True,
@ -17,130 +16,46 @@ class CrmTeam(models.Model):
"of the current and target revenue on the kanban view.")
invoiced_target = fields.Float(
string='Invoicing Target',
help="Revenue target for the current month (untaxed total of confirmed invoices).")
quotations_count = fields.Integer(
compute='_compute_quotations_to_invoice',
string='Number of quotations to invoice', readonly=True)
quotations_amount = fields.Float(
compute='_compute_quotations_to_invoice',
string='Amount of quotations to invoice', readonly=True)
sales_to_invoice_count = fields.Integer(
compute='_compute_sales_to_invoice',
string='Number of sales to invoice', readonly=True)
help="Revenue Target for the current month (untaxed total of paid invoices).")
sale_order_count = fields.Integer(compute='_compute_sale_order_count', string='# Sale Orders')
def _compute_quotations_to_invoice(self):
query = self.env['sale.order']._where_calc([
('team_id', 'in', self.ids),
('state', 'in', ['draft', 'sent']),
])
self.env['sale.order']._apply_ir_rules(query, 'read')
_, where_clause, where_clause_args = query.get_sql()
select_query = """
SELECT team_id, count(*), sum(amount_total /
CASE COALESCE(currency_rate, 0)
WHEN 0 THEN 1.0
ELSE currency_rate
END
) as amount_total
FROM sale_order
WHERE %s
GROUP BY team_id
""" % where_clause
self.env.cr.execute(select_query, where_clause_args)
quotation_data = self.env.cr.dictfetchall()
teams = self.browse()
for datum in quotation_data:
team = self.browse(datum['team_id'])
team.quotations_amount = datum['amount_total']
team.quotations_count = datum['count']
teams |= team
remaining = (self - teams)
remaining.quotations_amount = 0
remaining.quotations_count = 0
def _compute_sales_to_invoice(self):
sale_order_data = self.env['sale.order']._read_group([
('team_id', 'in', self.ids),
('invoice_status','=','to invoice'),
], ['team_id'], ['team_id'])
data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data}
for team in self:
team.sales_to_invoice_count = data_map.get(team.id,0.0)
def _compute_invoiced(self):
if not self:
return
if self.ids:
today = fields.Date.today()
data_map = dict(self.env.execute_query(SQL(
''' SELECT
move.team_id AS team_id,
SUM(move.amount_untaxed_signed) AS amount_untaxed_signed
FROM account_move move
WHERE move.move_type IN ('out_invoice', 'out_refund', 'out_receipt')
AND move.payment_state IN ('in_payment', 'paid', 'reversed')
AND move.state = 'posted'
AND move.team_id IN %s
AND move.date BETWEEN %s AND %s
GROUP BY move.team_id
''',
tuple(self.ids),
fields.Date.to_string(today.replace(day=1)),
fields.Date.to_string(today),
)))
else:
data_map = {}
query = '''
SELECT
move.team_id AS team_id,
SUM(move.amount_untaxed_signed) AS amount_untaxed_signed
FROM account_move move
WHERE move.move_type IN ('out_invoice', 'out_refund', 'out_receipt')
AND move.payment_state IN ('in_payment', 'paid', 'reversed')
AND move.state = 'posted'
AND move.team_id IN %s
AND move.date BETWEEN %s AND %s
GROUP BY move.team_id
'''
today = fields.Date.today()
params = [tuple(self.ids), fields.Date.to_string(today.replace(day=1)), fields.Date.to_string(today)]
self._cr.execute(query, params)
data_map = dict((v[0], v[1]) for v in self._cr.fetchall())
for team in self:
team.invoiced = data_map.get(team.id, 0.0)
team.invoiced = data_map.get(team._origin.id, 0.0)
def _compute_sale_order_count(self):
data_map = {}
if self.ids:
sale_order_data = self.env['sale.order']._read_group([
('team_id', 'in', self.ids),
('state', '!=', 'cancel'),
], ['team_id'], ['team_id'])
data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data}
sale_order_data = self.env['sale.order']._read_group([
('team_id', 'in', self.ids),
('state', '!=', 'cancel'),
], ['team_id'], ['__count'])
data_map = {team.id: count for team, count in sale_order_data}
for team in self:
team.sale_order_count = data_map.get(team.id, 0)
def _in_sale_scope(self):
return self.env.context.get('in_sales_app')
def _graph_get_model(self):
if self._in_sale_scope():
return 'sale.report'
return super()._graph_get_model()
def _graph_date_column(self):
if self._in_sale_scope():
return 'date'
return super()._graph_date_column()
def _graph_get_table(self, GraphModel):
if self._in_sale_scope():
# For a team not shared between company, we make sure the amounts are expressed
# in the currency of the team company and not converted to the current company currency,
# as the amounts of the sale report are converted in the currency
# of the current company (for multi-company reporting, see #83550)
GraphModel = GraphModel.with_company(self.company_id)
return f"({GraphModel._table_query}) AS {GraphModel._table}"
return super()._graph_get_table(GraphModel)
def _graph_y_query(self):
if self._in_sale_scope():
return 'SUM(price_subtotal)'
return super()._graph_y_query()
def _extra_sql_conditions(self):
if self._in_sale_scope():
return "AND state in ('sale', 'done', 'pos_done')"
return super()._extra_sql_conditions()
def _graph_title_and_key(self):
if self._in_sale_scope():
return ['', _('Sales: Untaxed Total')] # no more title
return super()._graph_title_and_key()
def _compute_dashboard_button_name(self):
super(CrmTeam,self)._compute_dashboard_button_name()
if self._in_sale_scope():
@ -163,7 +78,7 @@ class CrmTeam(models.Model):
for team in self:
if team.sale_order_count >= SO_COUNT_TRIGGER:
raise UserError(
_('Team %(team_name)s has %(sale_order_count)s active sale orders. Consider canceling them or archiving the team instead.',
_('Team %(team_name)s has %(sale_order_count)s active sale orders. Consider cancelling them or archiving the team instead.',
team_name=team.name,
sale_order_count=team.sale_order_count
))

View file

@ -0,0 +1,56 @@
import io
from odoo import models
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
# EXTENDS base
collected_streams = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
if (
collected_streams
and res_ids
and len(res_ids) == 1
and self._is_sale_order_report(report_ref)
):
sale_order = self.env['sale.order'].browse(res_ids)
builders = sale_order._get_edi_builders()
if len(builders) == 0:
return collected_streams
# Read pdf content.
pdf_stream = collected_streams[sale_order.id]['stream']
pdf_content = pdf_stream.getvalue()
reader_buffer = io.BytesIO(pdf_content)
reader = OdooPdfFileReader(reader_buffer, strict=False)
writer = OdooPdfFileWriter()
writer.cloneReaderDocumentRoot(reader)
# Generate and attach EDI documents from each builder
for builder in builders:
xml_content = builder._export_order(sale_order)
writer.addAttachment(
builder._export_invoice_filename(sale_order), # works even if it's a SO or PO
xml_content,
subtype='text/xml'
)
# Replace the current content.
pdf_stream.close()
new_pdf_stream = io.BytesIO()
writer.write(new_pdf_stream)
collected_streams[sale_order.id]['stream'] = new_pdf_stream
return collected_streams
def _is_sale_order_report(self, report_ref):
return self._get_report(report_ref).report_name in (
'sale.report_saleorder_document',
'sale.report_saleorder',
'sale.report_saleorder_raw',
)

View file

@ -0,0 +1,45 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.tools.misc import str2bool
from odoo.addons.sale import const
class IrConfigParameter(models.Model):
_inherit = 'ir.config_parameter'
@api.model_create_multi
def create(self, vals_list):
configs = super().create(vals_list)
configs._sale_sync_linked_crons()
return configs
def write(self, vals):
res = super().write(vals)
self._sale_sync_linked_crons()
return res
def unlink(self):
self._sale_sync_linked_crons(unlink=True)
return super().unlink()
def _sale_sync_linked_crons(self, unlink=False):
"""Synchronize Sales-related crons' `active` field based on linked configuration parameters.
:param bool unlink: Whether this sync is triggered by parameter deletion.
:return: None
"""
param_cron_mapping = self._get_param_cron_mapping()
for config in self.filtered(lambda c: c.key in param_cron_mapping):
linked_cron_xmlid = param_cron_mapping[config.key]
if linked_cron := self.env.ref(linked_cron_xmlid, raise_if_not_found=False):
linked_cron.active = False if unlink else str2bool(config.value)
def _get_param_cron_mapping(self):
"""Return a mapping of config parameters to linked crons' XMLIDs.
:return: The config-cron mapping.
:rtype: dict
"""
return const.PARAM_CRON_MAPPING

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,13 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from datetime import datetime
from dateutil import relativedelta
from odoo import _, api, Command, fields, models, SUPERUSER_ID
from odoo.tools import format_amount, str2bool
_logger = logging.getLogger(__name__)
from odoo import SUPERUSER_ID, Command, _, api, fields, models
from odoo.tools import str2bool
class PaymentTransaction(models.Model):
@ -21,10 +19,12 @@ class PaymentTransaction(models.Model):
self.ensure_one()
if self.provider_id.so_reference_type == 'so_name':
order_reference = order.name
else:
# self.provider_id.so_reference_type == 'partner'
elif self.provider_id.so_reference_type == 'partner':
identification_number = order.partner_id.id
order_reference = '%s/%s' % ('CUST', str(identification_number % 97).rjust(2, '0'))
else:
# self.provider_id.so_reference_type is empty
order_reference = False
invoice_journal = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', self.env.company.id)], limit=1)
if invoice_journal:
@ -37,73 +37,95 @@ class PaymentTransaction(models.Model):
for trans in self:
trans.sale_order_ids_nbr = len(trans.sale_order_ids)
def _set_pending(self, state_message=None):
""" Override of `payment` to send the quotations automatically.
def _post_process(self):
""" Override of `payment` to add Sales-specific logic to the post-processing.
:param str state_message: The reason for which the transaction is set in 'pending' state.
:return: updated transactions.
:rtype: `payment.transaction` recordset.
In particular, for pending transactions, we send the quotation by email; for authorized
transactions, we confirm the quotation; for confirmed transactions, we automatically confirm
the quotation and generate invoices.
"""
txs_to_process = super()._set_pending(state_message=state_message)
for tx in txs_to_process: # Consider only transactions that are indeed set pending.
sales_orders = tx.sale_order_ids.filtered(lambda so: so.state in ['draft', 'sent'])
for pending_tx in self.filtered(lambda tx: tx.state == 'pending'):
super(PaymentTransaction, pending_tx)._post_process()
sales_orders = pending_tx.sale_order_ids.filtered(
lambda so: so.state in ['draft', 'sent']
)
sales_orders.filtered(
lambda so: so.state == 'draft'
).with_context(tracking_disable=True).action_quotation_sent()
if tx.provider_id.code == 'custom':
for so in tx.sale_order_ids:
so.reference = tx._compute_sale_order_reference(so)
# send order confirmation mail.
sales_orders._send_order_confirmation_mail()
if pending_tx.provider_id.code == 'custom':
for order in pending_tx.sale_order_ids:
order.reference = pending_tx._compute_sale_order_reference(order)
return txs_to_process
if pending_tx.operation == 'validation':
continue
# Send the payment status email.
# The transactions are manually cached while in a sudoed environment to prevent an
# AccessError: In some circumstances, sending the mail would generate the report assets
# during the rendering of the mail body, causing a cursor commit, a flush, and forcing
# the re-computation of the pending computed fields of the `mail.compose.message`,
# including part of the template. Since that template reads the order's transactions and
# the re-computation of the field is not done with the same environment, reading fields
# that were not already available in the cache could trigger an AccessError (e.g., if
# the payment was initiated by a public user).
sales_orders.mapped('transaction_ids')
sales_orders._send_payment_succeeded_for_order_mail()
for authorized_tx in self.filtered(lambda tx: tx.state == 'authorized'):
super(PaymentTransaction, authorized_tx)._post_process()
confirmed_orders = authorized_tx._check_amount_and_confirm_order()
if authorized_tx.operation == 'validation':
continue
if remaining_orders := (authorized_tx.sale_order_ids - confirmed_orders):
remaining_orders._send_payment_succeeded_for_order_mail()
super(PaymentTransaction, self.filtered(
lambda tx: tx.state not in ['pending', 'authorized', 'done'])
)._post_process()
for done_tx in self.filtered(lambda tx: tx.state == 'done'):
if done_tx.operation != 'validation':
confirmed_orders = done_tx._check_amount_and_confirm_order()
(done_tx.sale_order_ids - confirmed_orders)._send_payment_succeeded_for_order_mail()
auto_invoice = str2bool(
self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice')
)
if auto_invoice:
# Invoice the sales orders of confirmed transactions instead of only confirmed
# orders to create the invoice even if only a partial payment was made.
done_tx._invoice_sale_orders()
super(PaymentTransaction, done_tx)._post_process() # Post the invoices.
if auto_invoice and not self.env.context.get('skip_sale_auto_invoice_send'):
if (
str2bool(self.env['ir.config_parameter'].sudo().get_param('sale.async_emails'))
and (send_invoice_cron := self.env.ref('sale.send_invoice_cron', raise_if_not_found=False))
):
send_invoice_cron._trigger()
else:
self._send_invoice()
def _check_amount_and_confirm_order(self):
""" Confirm the sales order based on the amount of a transaction.
Confirm the sales orders only if the transaction amount is equal to the total amount of the
sales orders. Neither partial payments nor grouped payments (paying multiple sales orders in
one transaction) are not supported.
Confirm the sales orders only if the transaction amount (or the sum of the partial
transaction amounts) is equal to or greater than the required amount for order confirmation
Grouped payments (paying multiple sales orders in one transaction) are not supported.
:return: The confirmed sales orders.
:rtype: a `sale.order` recordset
"""
confirmed_orders = self.env['sale.order']
for tx in self:
# We only support the flow where exactly one quotation is linked to a transaction and
# vice versa.
# We only support the flow where exactly one quotation is linked to a transaction.
if len(tx.sale_order_ids) == 1:
quotation = tx.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent'))
if quotation and len(quotation.transaction_ids.filtered(
lambda tx: tx.state in ('authorized', 'done') # Only consider confirmed tx
)) == 1:
# Check if the SO is fully paid
if quotation.currency_id.compare_amounts(tx.amount, quotation.amount_total) == 0:
quotation.with_context(send_email=True).action_confirm()
confirmed_orders |= quotation
else:
_logger.warning(
'<%(provider)s> transaction AMOUNT MISMATCH for order %(so_name)s '
'(ID %(so_id)s): expected %(so_amount)s, got %(tx_amount)s', {
'provider': tx.provider_code,
'so_name': quotation.name,
'so_id': quotation.id,
'so_amount': format_amount(
quotation.env, quotation.amount_total, quotation.currency_id
),
'tx_amount': format_amount(tx.env, tx.amount, tx.currency_id),
},
)
if quotation and quotation._is_confirmation_amount_reached():
quotation.with_context(send_email=True).action_confirm()
confirmed_orders |= quotation
return confirmed_orders
def _set_authorized(self, state_message=None):
""" Override of payment to confirm the quotations automatically. """
super()._set_authorized(state_message=state_message)
confirmed_orders = self._check_amount_and_confirm_order()
confirmed_orders._send_order_confirmation_mail()
def _log_message_on_linked_documents(self, message):
""" Override of payment to log a message on the sales orders linked to the transaction.
@ -113,35 +135,18 @@ class PaymentTransaction(models.Model):
:return: None
"""
super()._log_message_on_linked_documents(message)
self = self.with_user(SUPERUSER_ID) # Log messages as 'OdooBot'
for order in self.sale_order_ids:
order.message_post(body=message)
def _reconcile_after_done(self):
""" Override of payment to automatically confirm quotations and generate invoices. """
confirmed_orders = self._check_amount_and_confirm_order()
confirmed_orders._send_order_confirmation_mail()
auto_invoice = str2bool(
self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'))
if auto_invoice:
# Invoice the sale orders in self instead of in confirmed_orders to create the invoice
# even if only a partial payment was made.
self._invoice_sale_orders()
super()._reconcile_after_done()
if auto_invoice:
# Must be called after the super() call to make sure the invoice are correctly posted.
self._send_invoice()
if self.env.uid == SUPERUSER_ID or self.env.context.get('payment_backend_action'):
author = self.env.user.partner_id
else:
author = self.partner_id
for order in self.sale_order_ids or self.source_transaction_id.sale_order_ids:
order.message_post(body=message, author_id=author.id)
def _send_invoice(self):
template_id = self.env['ir.config_parameter'].sudo().get_param(
'sale.default_invoice_email_template'
)
if not template_id:
return
template_id = int(template_id)
template = self.env['mail.template'].browse(template_id)
for tx in self:
# Send messages as OdooBot so that
# * logged in users receive the invoice
# * the mail and notifications are not sent by the public user
for tx in self.with_user(SUPERUSER_ID):
tx = tx.with_company(tx.company_id).with_context(
company_id=tx.company_id.id,
)
@ -149,15 +154,22 @@ class PaymentTransaction(models.Model):
lambda i: not i.is_move_sent and i.state == 'posted' and i._is_ready_to_be_sent()
)
invoice_to_send.is_move_sent = True # Mark invoice as sent
for invoice in invoice_to_send:
lang = template._render_lang(invoice.ids)[invoice.id]
model_desc = invoice.with_context(lang=lang).type_name
invoice.with_context(model_description=model_desc).with_user(
SUPERUSER_ID
).message_post_with_template(
template_id=template_id,
email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature',
)
send_context = {'allow_raising': False, 'allow_fallback_pdf': True}
default_template_param = (
self.env['ir.config_parameter']
.sudo()
.get_param('sale.default_invoice_email_template', False)
)
if default_template_param:
mail_template = self.env['mail.template'].sudo().browse(int(default_template_param))
if mail_template.exists():
send_context['mail_template'] = mail_template
tx.env['account.move.send']._generate_and_send_invoices(
invoice_to_send,
**send_context,
)
def _cron_send_invoice(self):
"""
@ -176,20 +188,31 @@ class PaymentTransaction(models.Model):
('is_move_sent', '=', False),
('state', '=', 'posted'),
])),
('sale_order_ids.state', 'in', ('sale', 'done')),
('sale_order_ids.state', '=', 'sale'),
('last_state_change', '>=', retry_limit_date),
])._send_invoice()
def _invoice_sale_orders(self):
for tx in self.filtered(lambda tx: tx.sale_order_ids):
# Create invoices
tx = tx.with_company(tx.company_id).with_context(company_id=tx.company_id.id)
confirmed_orders = tx.sale_order_ids.filtered(lambda so: so.state in ('sale', 'done'))
tx = tx.with_company(tx.company_id)
confirmed_orders = tx.sale_order_ids.filtered(lambda so: so.state == 'sale')
if confirmed_orders:
confirmed_orders._force_lines_to_invoice_policy_order()
invoices = confirmed_orders.with_context(
# Filter orders between those fully paid and those partially paid.
fully_paid_orders = confirmed_orders.filtered(lambda so: so._is_paid())
# Create a down payment invoice for partially paid orders
downpayment_invoices = (
confirmed_orders - fully_paid_orders
)._generate_downpayment_invoices()
# For fully paid orders create a final invoice.
fully_paid_orders._force_lines_to_invoice_policy_order()
final_invoices = fully_paid_orders.with_context(
raise_if_nothing_to_invoice=False
)._create_invoices()
)._create_invoices(final=True)
invoices = downpayment_invoices + final_invoices
# Setup access token in advance to avoid serialization failure between
# edi postprocessing of invoice and displaying the sale order on the portal
for invoice in invoices:
@ -197,14 +220,13 @@ class PaymentTransaction(models.Model):
tx.invoice_ids = [Command.set(invoices.ids)]
@api.model
def _compute_reference_prefix(self, provider_code, separator, **values):
def _compute_reference_prefix(self, separator, **values):
""" Override of payment to compute the reference prefix based on Sales-specific values.
If the `values` parameter has an entry with 'sale_order_ids' as key and a list of (4, id, O)
or (6, 0, ids) X2M command as value, the prefix is computed based on the sales order name(s)
Otherwise, the computation is delegated to the super method.
:param str provider_code: The code of the provider handling the transaction
:param str separator: The custom separator used to separate data references
:param dict values: The transaction values used to compute the reference prefix. It should
have the structure {'sale_order_ids': [(X2M command), ...], ...}.
@ -218,8 +240,9 @@ class PaymentTransaction(models.Model):
orders = self.env['sale.order'].browse(order_ids).exists()
if len(orders) == len(order_ids): # All ids are valid
return separator.join(orders.mapped('name'))
return super()._compute_reference_prefix(provider_code, separator, **values)
return super()._compute_reference_prefix(separator, **values)
@api.readonly
def action_view_sales_orders(self):
action = {
'name': _('Sales Order(s)'),
@ -232,6 +255,6 @@ class PaymentTransaction(models.Model):
action['res_id'] = sale_order_ids[0]
action['view_mode'] = 'form'
else:
action['view_mode'] = 'tree,form'
action['view_mode'] = 'list,form'
action['domain'] = [('id', 'in', sale_order_ids)]
return action

View file

@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductDocument(models.Model):
_inherit = 'product.document'
attached_on_sale = fields.Selection(
selection=[
('hidden', "Hidden"),
('quotation', "On quote"),
('sale_order', "On confirmed order"),
],
required=True,
default='hidden',
string="Sale : Visible at",
help="Allows you to share the document with your customers within a sale.\n"
"On quote: the document will be sent to and accessible by customers at any time.\n"
"e.g. this option can be useful to share Product description files.\n"
"On order confirmation: the document will be sent to and accessible by customers.\n"
"e.g. this option can be useful to share User Manual or digital content bought"
" on ecommerce. ",
groups='sales_team.group_sale_salesman',
)

View file

@ -0,0 +1,16 @@
from odoo import api, models
class ProductPricelistItem(models.Model):
_inherit = 'product.pricelist.item'
@api.model
def _is_discount_feature_enabled(self):
return self.env['res.groups']._is_feature_enabled('sale.group_discount_per_so_line')
def _show_discount(self):
if not self:
return False
self.ensure_one()
return self._is_discount_feature_enabled() and self.compute_price == 'percentage'

View file

@ -1,22 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta, time
from odoo import fields, models, _, api
from odoo.tools.float_utils import float_round
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_round
class ProductProduct(models.Model):
_inherit = 'product.product'
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit')
# Catalog related fields
product_catalog_product_is_in_sale_order = fields.Boolean(
compute='_compute_product_is_in_sale_order',
search='_search_product_is_in_sale_order',
)
def _compute_sales_count(self):
r = {}
self.sales_count = 0
if not self.user_has_groups('sales_team.group_sale_salesman'):
if not self.env.user.has_group('sales_team.group_sale_salesman'):
return r
date_from = fields.Datetime.to_string(fields.datetime.combine(fields.datetime.now() - timedelta(days=365),
time.min))
date_from = fields.Date.today() - timedelta(days=365)
done_states = self.env['sale.report']._get_done_states()
@ -25,13 +32,13 @@ class ProductProduct(models.Model):
('product_id', 'in', self.ids),
('date', '>=', date_from),
]
for group in self.env['sale.report']._read_group(domain, ['product_id', 'product_uom_qty'], ['product_id']):
r[group['product_id'][0]] = group['product_uom_qty']
for product, product_uom_qty in self.env['sale.report']._read_group(domain, ['product_id'], ['product_uom_qty:sum']):
r[product.id] = product_uom_qty
for product in self:
if not product.id:
product.sales_count = 0.0
continue
product.sales_count = float_round(r.get(product.id, 0), precision_rounding=product.uom_id.rounding)
product.sales_count = product.uom_id.round(r.get(product.id, 0))
return r
@api.onchange('type')
@ -42,45 +49,86 @@ class ProductProduct(models.Model):
'message': _("You cannot change the product's type because it is already used in sales orders.")
}}
@api.depends_context('order_id')
def _compute_product_is_in_sale_order(self):
order_id = self.env.context.get('order_id')
if not order_id:
self.product_catalog_product_is_in_sale_order = False
return
read_group_data = self.env['sale.order.line']._read_group(
domain=[('order_id', '=', order_id)],
groupby=['product_id'],
aggregates=['__count'],
)
data = {product.id: count for product, count in read_group_data}
for product in self:
product.product_catalog_product_is_in_sale_order = bool(data.get(product.id, 0))
def _search_product_is_in_sale_order(self, operator, value):
if operator != 'in':
return NotImplemented
product_ids = self.env['sale.order.line'].search_fetch([
('order_id', 'in', [self.env.context.get('order_id', '')]),
], ['product_id']).product_id.ids
return [('id', 'in', product_ids)]
@api.readonly
def action_view_sales(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
action['domain'] = [('product_id', 'in', self.ids)]
action['context'] = {
'pivot_measures': ['product_uom_qty'],
'active_id': self._context.get('active_id'),
'active_id': self.env.context.get('active_id'),
'search_default_Sales': 1,
'active_model': 'sale.report',
'search_default_filter_order_date': 1,
}
return action
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('sale.sale_menu_root').id]
def _get_invoice_policy(self):
return self.invoice_policy
def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False):
"""Return the variant info based on its combination.
See `_get_combination_info` for more information.
"""
self.ensure_one()
return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
def _filter_to_unlink(self):
domain = [('product_id', 'in', self.ids)]
lines = self.env['sale.order.line']._read_group(domain, ['product_id'], ['product_id'])
linked_product_ids = [group['product_id'][0] for group in lines]
lines = self.env['sale.order.line']._read_group(domain, ['product_id'])
linked_product_ids = [product.id for [product] in lines]
return super(ProductProduct, self - self.browse(linked_product_ids))._filter_to_unlink()
def _update_uom(self, to_uom_id):
for uom, product, so_lines in self.env['sale.order.line']._read_group(
[('product_id', 'in', self.ids)],
['product_uom_id', 'product_id'],
['id:recordset'],
):
if so_lines.product_uom_id != product.product_tmpl_id.uom_id:
raise UserError(_(
'As other units of measure (ex : %(problem_uom)s) '
'than %(uom)s have already been used for this product, the change of unit of measure can not be done.'
'If you want to change it, please archive the product and create a new one.',
problem_uom=uom.display_name, uom=product.product_tmpl_id.uom_id.display_name))
so_lines.product_uom_id = to_uom_id
return super()._update_uom(to_uom_id)
def _trigger_uom_warning(self):
res = super()._trigger_uom_warning()
if res:
return res
so_lines = self.env['sale.order.line'].sudo().search_count(
[('product_id', 'in', self.ids)], limit=1
)
return bool(so_lines)
class ProductAttributeCustomValue(models.Model):
_inherit = "product.attribute.custom.value"
sale_order_line_id = fields.Many2one('sale.order.line', string="Sales Order Line", required=True, ondelete='cascade')
sale_order_line_id = fields.Many2one('sale.order.line', string="Sales Order Line", index='btree_not_null', ondelete='cascade')
_sql_constraints = [
('sol_custom_value_unique', 'unique(custom_product_template_attribute_value_id, sale_order_line_id)', "Only one Custom Value is allowed per Attribute Value per Sales Order Line.")
]
class ProductPackaging(models.Model):
_inherit = 'product.packaging'
sales = fields.Boolean("Sales", default=True, help="If true, the packaging can be used for sales orders")
_sol_custom_value_unique = models.Constraint(
'unique(custom_product_template_attribute_value_id, sale_order_line_id)',
'Only one Custom Value is allowed per Attribute Value per Sales Order Line.',
)

View file

@ -1,57 +1,100 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
import json
import logging
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.float_utils import float_round
_logger = logging.getLogger(__name__)
from odoo.tools import float_round
class ProductTemplate(models.Model):
_inherit = 'product.template'
_check_company_auto = True
service_type = fields.Selection(
[('manual', 'Manually set quantities on order')], string='Track Service',
selection=[('manual', "Manually set quantities on order")],
string="Track Service",
compute='_compute_service_type', store=True, readonly=False, precompute=True,
help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
"Create a task and track hours: Create a task on the sales order validation and track the work hours.")
sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
sale_line_warn_msg = fields.Text(string="Sales Order Line Warning")
expense_policy = fields.Selection(
[('no', 'No'),
('cost', 'At cost'),
('sales_price', 'Sales price')
], string='Re-Invoice Expenses', default='no',
selection=[
('no', "No"),
('cost', "At cost"),
('sales_price', "Sales price"),
],
string="Re-Invoice Costs", default='no',
compute='_compute_expense_policy', store=True, readonly=False,
help="Expenses and vendor bills can be re-invoiced to a customer."
"With this option, a validated expense can be re-invoice to a customer at its cost or sales price.")
visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator')
help="Validated expenses, vendor bills, or stock pickings (set up to track costs) can be invoiced to the customer at either cost or sales price.")
visible_expense_policy = fields.Boolean(
string="Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
sales_count = fields.Float(
string="Sold", compute='_compute_sales_count', digits='Product Unit')
invoice_policy = fields.Selection(
[('order', 'Ordered quantities'),
('delivery', 'Delivered quantities')], string='Invoicing Policy',
compute='_compute_invoice_policy', store=True, readonly=False, precompute=True,
help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
'Delivered Quantity: Invoice quantities delivered to the customer.')
selection=[
('order', "Ordered quantities"),
('delivery', "Delivered quantities"),
],
string="Invoicing Policy",
compute='_compute_invoice_policy',
precompute=True,
store=True,
readonly=False,
tracking=True,
help="Ordered Quantity: Invoice quantities ordered by the customer.\n"
"Delivered Quantity: Invoice quantities delivered to the customer.")
optional_product_ids = fields.Many2many(
comodel_name='product.template',
relation='product_optional_rel',
column1='src_id',
column2='dest_id',
string="Optional Products",
help="Optional Products are suggested "
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
"e.g. for computers: warranty, software, etc.).",
check_company=True)
def _compute_visible_qty_configurator(self):
for product_template in self:
product_template.visible_qty_configurator = True
@api.depends('invoice_policy', 'sale_ok', 'service_tracking')
def _compute_product_tooltip(self):
super()._compute_product_tooltip()
@api.depends('name')
def _prepare_tooltip(self):
tooltip = super()._prepare_tooltip()
if not self.sale_ok:
return tooltip
invoicing_tooltip = self._prepare_invoicing_tooltip()
tooltip = f'{tooltip} {invoicing_tooltip}' if tooltip else invoicing_tooltip
if self.type == 'service':
additional_tooltip = self._prepare_service_tracking_tooltip()
tooltip = f'{tooltip} {additional_tooltip}' if additional_tooltip else tooltip
return tooltip
def _prepare_invoicing_tooltip(self):
if self.invoice_policy == 'delivery' and self.type != 'consu':
return _("Invoice after delivery, based on quantities delivered, not ordered.")
elif self.invoice_policy == 'order' and self.type == 'service':
return _("Invoice ordered quantities as soon as this service is sold.")
return ""
def _prepare_service_tracking_tooltip(self):
return ""
@api.depends('sale_ok')
def _compute_service_tracking(self):
super()._compute_service_tracking()
self.filtered(lambda pt: not pt.sale_ok).service_tracking = 'no'
@api.depends('purchase_ok')
def _compute_visible_expense_policy(self):
visibility = self.user_has_groups('analytic.group_analytic_accounting')
visibility = self.env.user.has_group('analytic.group_analytic_accounting')
for product_template in self:
product_template.visible_expense_policy = visibility
product_template.visible_expense_policy = visibility and product_template.purchase_ok
@api.depends('sale_ok')
def _compute_expense_policy(self):
@ -60,26 +103,25 @@ class ProductTemplate(models.Model):
@api.depends('product_variant_ids.sales_count')
def _compute_sales_count(self):
for product in self:
product.sales_count = float_round(sum([p.sales_count for p in product.with_context(active_test=False).product_variant_ids]), precision_rounding=product.uom_id.rounding)
product.sales_count = product.uom_id.round(sum(p.sales_count for p in product.with_context(active_test=False).product_variant_ids))
@api.constrains('company_id')
def _check_sale_product_company(self):
"""Ensure the product is not being restricted to a single company while
having been sold in another one in the past, as this could cause issues."""
products_by_company = defaultdict(lambda: self.env['product.template'])
products_by_compagny = defaultdict(lambda: self.env['product.template'])
for product in self:
if not product.product_variant_ids or not product.company_id:
# No need to check if the product has just being created (`product_variant_ids` is
# still empty) or if we're writing `False` on its company (should always work.)
continue
products_by_company[product.company_id] |= product
products_by_compagny[product.company_id] |= product
for target_company, products in products_by_company.items():
for target_company, products in products_by_compagny.items():
subquery_products = self.env['product.product'].sudo().with_context(active_test=False)._search([('product_tmpl_id', 'in', products.ids)])
so_lines = self.env['sale.order.line'].sudo().search_read(
[('product_id', 'in', subquery_products), ('company_id', '!=', target_company.id)],
fields=['id', 'product_id']
)
[('product_id', 'in', subquery_products), '!', ('company_id', 'child_of', target_company.id)],
fields=['id', 'product_id'])
if so_lines:
used_products = [sol['product_id'][1] for sol in so_lines]
raise ValidationError(_('The following products cannot be restricted to the company'
@ -89,57 +131,23 @@ class ProductTemplate(models.Model):
'with your company restriction instead, or leave them as '
'shared product.', company=target_company.name, used_products=', '.join(used_products)))
@api.readonly
def action_view_sales(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
action = self.env['ir.actions.actions']._for_xml_id('sale.report_all_channels_sales_action')
action['domain'] = [('product_tmpl_id', 'in', self.ids)]
action['context'] = {
'pivot_measures': ['product_uom_qty'],
'active_id': self._context.get('active_id'),
'active_id': self.env.context.get('active_id'),
'active_model': 'sale.report',
'search_default_Sales': 1,
'search_default_filter_order_date': 1,
'search_default_group_by_date': 1,
}
return action
def create_product_variant(self, product_template_attribute_value_ids):
""" Create if necessary and possible and return the id of the product
variant matching the given combination for this template.
Note AWA: Known "exploit" issues with this method:
- This method could be used by an unauthenticated user to generate a
lot of useless variants. Unfortunately, after discussing the
matter with ODO, there's no easy and user-friendly way to block
that behavior.
We would have to use captcha/server actions to clean/... that
are all not user-friendly/overkill mechanisms.
- This method could be used to try to guess what product variant ids
are created in the system and what product template ids are
configured as "dynamic", but that does not seem like a big deal.
The error messages are identical on purpose to avoid giving too much
information to a potential attacker:
- returning 0 when failing
- returning the variant id whether it already existed or not
:param product_template_attribute_value_ids: the combination for which
to get or create variant
:type product_template_attribute_value_ids: list of id
of `product.template.attribute.value`
:return: id of the product variant matching the combination or 0
:rtype: int
"""
combination = self.env['product.template.attribute.value'] \
.browse(product_template_attribute_value_ids)
return self._create_product_variant(combination, log_warning=True).id or 0
@api.onchange('type')
def _onchange_type(self):
res = super(ProductTemplate, self)._onchange_type()
res = super()._onchange_type()
if self._origin and self.sales_count > 0:
res['warning'] = {
'title': _("Warning"),
@ -155,175 +163,149 @@ class ProductTemplate(models.Model):
def _compute_invoice_policy(self):
self.filtered(lambda t: t.type == 'consu' or not t.invoice_policy).invoice_policy = 'order'
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('sale.sale_menu_root').id]
@api.model
def get_import_templates(self):
res = super(ProductTemplate, self).get_import_templates()
if self.env.context.get('sale_multi_pricelist_product_template'):
if self.user_has_groups('product.group_sale_pricelist'):
if self.env.user.has_group('product.group_product_pricelist'):
return [{
'label': _('Import Template for Products'),
'label': _("Import Template for Products"),
'template': '/product/static/xls/product_template.xls'
}]
return res
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
""" Return info about a given combination.
@api.model
def _get_incompatible_types(self):
return []
Note: this method does not take into account whether the combination is
actually possible.
@api.constrains(lambda self: self._get_incompatible_types())
def _check_incompatible_types(self):
incompatible_types = self._get_incompatible_types()
if len(incompatible_types) < 2:
return
fields = self.env['ir.model.fields'].sudo().search_read(
[('model', '=', 'product.template'), ('name', 'in', incompatible_types)],
['name', 'field_description'])
field_descriptions = {v['name']: v['field_description'] for v in fields}
field_list = incompatible_types + ['name']
values = self.read(field_list)
for val in values:
incompatible_fields = [f for f in incompatible_types if val[f]]
if len(incompatible_fields) > 1:
raise ValidationError(_(
"The product (%(product)s) has incompatible values: %(value_list)s",
product=val['name'],
value_list=[field_descriptions[v] for v in incompatible_fields],
))
:param combination: recordset of `product.template.attribute.value`
def get_single_product_variant(self):
""" Method used by the product configurator to check if the product is configurable or not.
:param product_id: id of a `product.product`. If no `combination`
is set, the method will try to load the variant `product_id` if
it exists instead of finding a variant based on the combination.
We need to open the product configurator if the product:
- is configurable (see has_configurable_attributes)
- has optional products """
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
):
has_optional_products = True
break
res.update({
'has_optional_products': has_optional_products,
'is_combo': self.type == 'combo',
})
return res
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
@api.model
def _get_saleable_tracking_types(self):
"""Return list of salealbe service_tracking types.
:param add_qty: float with the quantity for which to get the info,
indeed some pricelist rules might depend on it.
:param pricelist: `product.pricelist` the pricelist to use
(can be none, eg. from SO if no partner and no pricelist selected)
:param parent_combination: if no combination and no product_id are
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:param only_template: boolean, if set to True, get the info for the
template only: ignore combination and don't try to find variant
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
- product_template_id: the current template id
- display_name: the name of the combination
- price: the computed price of the combination, take the catalog
price if no pricelist is given
- list_price: the catalog price of the combination, but this is
not the "real" list_price, it has price_extra included (so
it's actually more closely related to `lst_price`), and it
is converted to the pricelist currency (if given)
- has_discounted_price: True if the pricelist discount policy says
the price does not include the discount and there is actually a
discount applied (price < list_price), else False
:rtype: list
"""
self.ensure_one()
# get the name before the change of context to benefit from prefetch
display_name = self.display_name
return ['no']
display_image = True
quantity = self.env.context.get('quantity', add_qty)
product_template = self
####################################
# Product/combo configurator hooks #
####################################
combination = combination or product_template.env['product.template.attribute.value']
@api.model
def _get_configurator_display_price(
self, product_or_template, quantity, date, currency, pricelist, **kwargs
):
""" Return the specified product's display price, to be used by the product and combo
configurators.
if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
This is a hook meant to customize the display price computation in overriding modules.
if only_template:
product = product_template.env['product.product']
elif product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
if product:
# We need to add the price_extra for the attributes that are not
# in the variant, typically those of type no_variant, but it is
# possible that a no_variant attribute is still in a variant if
# the type of the attribute has been changed after creation.
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in combination.filtered(
lambda ptav:
ptav.price_extra and
ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
product = product.with_context(
no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra)
)
list_price = product.price_compute('list_price')[product.id]
if pricelist:
price = pricelist._get_product_price(product, quantity)
else:
price = list_price
display_image = bool(product.image_128)
display_name = product.display_name
price_extra = (product.price_extra or 0.0) + (sum(no_variant_attributes_price_extra) or 0.0)
else:
current_attributes_price_extra = [v.price_extra or 0.0 for v in combination]
product_template = product_template.with_context(current_attributes_price_extra=current_attributes_price_extra)
price_extra = sum(current_attributes_price_extra)
list_price = product_template.price_compute('list_price')[product_template.id]
if pricelist:
price = pricelist._get_product_price(product_template, quantity)
else:
price = list_price
display_image = bool(product_template.image_128)
combination_name = combination._get_combination_name()
if combination_name:
display_name = "%s (%s)" % (display_name, combination_name)
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
price_extra = product_template.currency_id._convert(
price_extra, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
fields.Date.today()
)
price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
return {
'product_id': product.id,
'product_template_id': product_template.id,
'display_name': display_name,
'display_image': display_image,
'price': price,
'list_price': list_price,
'price_extra': price_extra,
'has_discounted_price': has_discounted_price,
}
def _can_be_added_to_cart(self):
:param product.product|product.template product_or_template: The product for which to get
the price.
:param int quantity: The quantity of the product.
:param datetime date: The date to use to compute the price.
:param res.currency currency: The currency to use to compute the price.
:param product.pricelist pricelist: The pricelist to use to compute the price.
:param dict kwargs: Locally unused data passed to `_get_configurator_price`.
:rtype: tuple(float, int or False)
:return: The specified product's display price (and the applied pricelist rule)
"""
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
return self._get_configurator_price(
product_or_template, quantity, date, currency, pricelist, **kwargs
)
@api.model
def _get_configurator_price(
self, product_or_template, quantity, date, currency, pricelist, **kwargs
):
""" Return the specified product's price, to be used by the product and combo configurators.
This is a hook meant to customize the price computation in overriding modules.
This hook has been extracted from `_get_configurator_display_price` because the price
computation can be overridden in 2 ways:
- Either by transforming super's price (e.g. in `website_sale`, we apply taxes to the
price),
- Or by computing a different price (e.g. in `sale_subscription`, we ignore super when
computing subscription prices).
In some cases, the order of the overrides matters, which is why we need 2 separate methods
(e.g. in `website_sale_subscription`, we must compute the subscription price before applying
taxes).
:param product.product|product.template product_or_template: The product for which to get
the price.
:param int quantity: The quantity of the product.
:param datetime date: The date to use to compute the price.
:param res.currency currency: The currency to use to compute the price.
:param product.pricelist pricelist: The pricelist to use to compute the price.
:param dict kwargs: Locally unused data passed to `_get_product_price`.
:rtype: tuple(float, int or False)
:return: The specified product's price (and the applied pricelist rule)
"""
return self.sale_ok
return pricelist._get_product_price_rule(
product_or_template, quantity=quantity, currency=currency, date=date, **kwargs
)
def _is_add_to_cart_possible(self, parent_combination=None):
@api.model
def _get_additional_configurator_data(
self, product_or_template, date, currency, pricelist, *, uom=None, **kwargs
):
"""Return additional data about the specified product.
This is a hook meant to append module-specific data in overriding modules.
:param product.product|product.template product_or_template: The product for which to get
additional data.
:param datetime date: The date to use to compute prices.
:param res.currency currency: The currency to use to compute prices.
:param product.pricelist pricelist: The pricelist to use to compute prices.
:param uom.uom uom: The uom to use to compute prices.
:param dict kwargs: Locally unused data passed to overrides.
:rtype: dict
:return: A dict containing additional data about the specified product.
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self.active or not self._can_be_added_to_cart():
# for performance: avoid calling `_get_possible_combinations`
return False
return next(self._get_possible_combinations(parent_combination), False) is not False
def _get_current_company_fallback(self, **kwargs):
"""Override: if a pricelist is given, fallback to the company of the
pricelist if it is set, otherwise use the one from parent method."""
res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
pricelist = kwargs.get('pricelist')
return pricelist and pricelist.company_id or res
return {}

View file

@ -1,135 +1,64 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import api, fields, models, _
from odoo.modules.module import get_module_resource
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ResCompany(models.Model):
_inherit = "res.company"
_inherit = 'res.company'
_check_company_auto = True
portal_confirmation_sign = fields.Boolean(string='Online Signature', default=True)
portal_confirmation_pay = fields.Boolean(string='Online Payment')
quotation_validity_days = fields.Integer(default=30, string="Default Quotation Validity (Days)")
_check_quotation_validity_days = models.Constraint(
'CHECK(quotation_validity_days >= 0)',
'You cannot set a negative number for the default quotation validity. Leave empty (or 0) to disable the automatic expiration of quotations.',
)
# sale quotation onboarding
sale_quotation_onboarding_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done"), ('closed', "Closed")], string="State of the sale onboarding panel", default='not_done')
sale_onboarding_order_confirmation_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding confirmation order step", default='not_done')
sale_onboarding_sample_quotation_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding sample quotation step", default='not_done')
portal_confirmation_sign = fields.Boolean(string="Online Signature", default=True)
portal_confirmation_pay = fields.Boolean(string="Online Payment")
prepayment_percent = fields.Float(
string="Prepayment percentage",
default=1.0,
help="The percentage of the amount needed to be paid to confirm quotations.")
quotation_validity_days = fields.Integer(
string="Default Quotation Validity",
default=30,
help="Days between quotation proposal and expiration."
" 0 days means automatic expiration is disabled",
)
sale_discount_product_id = fields.Many2one(
comodel_name='product.product',
string="Discount Product",
domain=[
('type', '=', 'service'),
('invoice_policy', '=', 'order'),
],
help="Default product used for discounts",
check_company=True,
)
sale_onboarding_payment_method = fields.Selection([
('digital_signature', 'Sign online'),
('paypal', 'PayPal'),
('stripe', 'Stripe'),
('other', 'Pay with another payment provider'),
('manual', 'Manual Payment'),
], string="Sale onboarding selected payment method")
# sale onboarding
sale_onboarding_payment_method = fields.Selection(
selection=[
('digital_signature', "Sign online"),
('paypal', "PayPal"),
('stripe', "Stripe"),
('other', "Pay with another payment provider"),
('manual', "Manual Payment"),
],
string="Sale onboarding selected payment method")
@api.model
def action_close_sale_quotation_onboarding(self):
""" Mark the onboarding panel as closed. """
self.env.company.sale_quotation_onboarding_state = 'closed'
downpayment_account_id = fields.Many2one(
comodel_name='account.account',
string="Downpayment Account",
domain=[
('account_type', 'in', ('income', 'income_other', 'liability_current')),
],
help="This account will be used on Downpayment invoices.",
tracking=True,
)
@api.model
def action_open_sale_onboarding_payment_provider(self):
""" Called by onboarding panel above the quotation list."""
self.env.company.get_chart_of_accounts_or_fail()
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_open_sale_payment_provider_onboarding_wizard")
return action
def _mark_payment_onboarding_step_as_done(self):
""" Override of payment to mark the sale onboarding step as done.
The payment onboarding step of Sales is only marked as done if it was started from Sales.
This prevents incorrectly marking the step as done if another module's payment onboarding
step was marked as done.
:return: None
"""
super()._mark_payment_onboarding_step_as_done()
if self.sale_onboarding_payment_method: # The onboarding step was started from Sales
self.set_onboarding_step_done('sale_onboarding_order_confirmation_state')
def _get_sample_sales_order(self):
""" Get a sample quotation or create one if it does not exist. """
# use current user as partner
partner = self.env.user.partner_id
company_id = self.env.company.id
# is there already one?
sample_sales_order = self.env['sale.order'].search(
[('company_id', '=', company_id), ('partner_id', '=', partner.id),
('state', '=', 'draft')], limit=1)
if len(sample_sales_order) == 0:
sample_sales_order = self.env['sale.order'].create({
'partner_id': partner.id
})
# take any existing product or create one
product = self.env['product.product'].search([], limit=1)
if len(product) == 0:
default_image_path = get_module_resource('product', 'static/img', 'product_product_13-image.jpg')
product = self.env['product.product'].create({
'name': _('Sample Product'),
'active': False,
'image_1920': base64.b64encode(open(default_image_path, 'rb').read())
})
product.product_tmpl_id.write({'active': False})
self.env['sale.order.line'].create({
'name': _('Sample Order Line'),
'product_id': product.id,
'product_uom_qty': 10,
'price_unit': 123,
'order_id': sample_sales_order.id,
'company_id': sample_sales_order.company_id.id,
})
return sample_sales_order
@api.model
def action_open_sale_onboarding_sample_quotation(self):
""" Onboarding step for sending a sample quotation. Open a window to compose an email,
with the edi_invoice_template message loaded by default. """
sample_sales_order = self._get_sample_sales_order()
template = self.env.ref('sale.email_template_edi_sale', False)
message_composer = self.env['mail.compose.message'].with_context(
default_use_template=bool(template),
mark_so_as_sent=True,
default_email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature',
proforma=self.env.context.get('proforma', False),
force_email=True, mail_notify_author=True
).create({
'res_id': sample_sales_order.id,
'template_id': template and template.id or False,
'model': 'sale.order',
'composition_mode': 'comment'})
# Simulate the onchange (like trigger in form the view)
update_values = message_composer._onchange_template_id(template.id, 'comment', 'sale.order', sample_sales_order.id)['value']
message_composer.write(update_values)
message_composer._action_send_mail()
self.set_onboarding_step_done('sale_onboarding_sample_quotation_state')
self.action_close_sale_quotation_onboarding()
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders")
action.update({
'views': [[self.env.ref('sale.view_order_form').id, 'form']],
'view_mode': 'form',
'target': 'main',
})
return action
def get_and_update_sale_quotation_onboarding_state(self):
""" This method is called on the controller rendering method and ensures that the animations
are displayed only one time. """
steps = [
'base_onboarding_company_state',
'account_onboarding_invoice_layout_state',
'sale_onboarding_order_confirmation_state',
'sale_onboarding_sample_quotation_state',
]
return self._get_and_update_onboarding_state('sale_quotation_onboarding_state', steps)
_sql_constraints = [('check_quotation_validity_days', 'CHECK(quotation_validity_days > 0)', 'Quotation Validity is required and must be greater than 0.')]
@api.constrains('prepayment_percent')
def _check_prepayment_percent(self):
for company in self:
if company.portal_confirmation_pay and not (0 < company.prepayment_percent <= 1.0):
raise ValidationError(_("Prepayment percentage must be a valid percentage."))

View file

@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# Defaults
default_invoice_policy = fields.Selection([
('order', 'Invoice what is ordered'),
('delivery', 'Invoice what is delivered')
], 'Invoicing Policy',
default='order',
default_model='product.template')
# Groups
group_auto_done_setting = fields.Boolean(
string="Lock Confirmed Sales", implied_group='sale.group_auto_done_setting')
group_proforma_sales = fields.Boolean(
string="Pro-Forma Invoice", implied_group='sale.group_proforma_sales',
help="Allows you to send pro-forma invoice.")
group_warning_sale = fields.Boolean("Sale Order Warnings", implied_group='sale.group_warning_sale')
# Config params
automatic_invoice = fields.Boolean(
string="Automatic Invoice",
help="The invoice is generated automatically and available in the customer portal when the "
"transaction is confirmed by the payment provider.\nThe invoice is marked as paid and "
"the payment is registered in the payment journal defined in the configuration of the "
"payment provider.\nThis mode is advised if you issue the final invoice at the order "
"and not after the delivery.",
config_parameter='sale.automatic_invoice',
)
deposit_default_product_id = fields.Many2one(
'product.product',
'Deposit Product',
domain="[('type', '=', 'service')]",
config_parameter='sale.default_deposit_product_id',
help='Default product used for payment advances')
invoice_mail_template_id = fields.Many2one(
comodel_name='mail.template',
string='Invoice Email Template',
domain="[('model', '=', 'account.move')]",
config_parameter='sale.default_invoice_email_template',
help="Email sent to the customer once the invoice is available.",
)
use_quotation_validity_days = fields.Boolean(
"Default Quotation Validity", config_parameter='sale.use_quotation_validity_days')
# Company setup
quotation_validity_days = fields.Integer(
related='company_id.quotation_validity_days', string="Default Quotation Validity (Days)", readonly=False)
portal_confirmation_sign = fields.Boolean(
related='company_id.portal_confirmation_sign', string='Online Signature', readonly=False)
portal_confirmation_pay = fields.Boolean(
related='company_id.portal_confirmation_pay', string='Online Payment', readonly=False)
# Modules
module_delivery = fields.Boolean("Delivery Methods")
module_delivery_bpost = fields.Boolean("bpost Connector")
module_delivery_dhl = fields.Boolean("DHL Express Connector")
module_delivery_easypost = fields.Boolean("Easypost Connector")
module_delivery_sendcloud = fields.Boolean("Sendcloud Connector")
module_delivery_fedex = fields.Boolean("FedEx Connector")
module_delivery_ups = fields.Boolean("UPS Connector")
module_delivery_usps = fields.Boolean("USPS Connector")
module_product_email_template = fields.Boolean("Specific Email")
module_sale_amazon = fields.Boolean("Amazon Sync")
module_sale_loyalty = fields.Boolean("Coupons & Loyalty")
module_sale_margin = fields.Boolean("Margins")
#=== ONCHANGE METHODS ===#
@api.onchange('use_quotation_validity_days')
def _onchange_use_quotation_validity_days(self):
if self.quotation_validity_days <= 0:
self.quotation_validity_days = self.env['res.company'].default_get(['quotation_validity_days'])['quotation_validity_days']
@api.onchange('quotation_validity_days')
def _onchange_quotation_validity_days(self):
if self.quotation_validity_days <= 0:
self.quotation_validity_days = self.env['res.company'].default_get(['quotation_validity_days'])['quotation_validity_days']
return {
'warning': {'title': _("Warning"), 'message': _("Quotation Validity is required and must be greater than 0.")},
}
#=== CRUD METHODS ===#
def set_values(self):
super().set_values()
if self.default_invoice_policy != 'order':
self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
send_invoice_cron = self.env.ref('sale.send_invoice_cron', raise_if_not_found=False)
if send_invoice_cron and send_invoice_cron.active != self.automatic_invoice:
send_invoice_cron.active = self.automatic_invoice

View file

@ -1,16 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.osv import expression
from odoo.fields import Domain
class ResPartner(models.Model):
_inherit = 'res.partner'
sale_order_count = fields.Integer(compute='_compute_sale_order_count', string='Sale Order Count')
sale_order_count = fields.Integer(
string="Sale Order Count",
groups='sales_team.group_sale_salesman',
compute='_compute_sale_order_count',
)
sale_order_ids = fields.One2many('sale.order', 'partner_id', 'Sales Order')
sale_warn = fields.Selection(WARNING_MESSAGE, 'Sales Warnings', default='no-message', help=WARNING_HELP)
sale_warn_msg = fields.Text('Message for Sales Order')
@api.model
@ -18,37 +20,91 @@ class ResPartner(models.Model):
return []
def _compute_sale_order_count(self):
# retrieve all children partners
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
self.sale_order_count = 0
if not self.env.user.has_group('sales_team.group_sale_salesman'):
return
sale_order_groups = self.env['sale.order']._read_group(
domain=expression.AND([self._get_sale_order_domain_count(), [('partner_id', 'in', all_partners.ids)]]),
fields=['partner_id'], groupby=['partner_id']
# retrieve all children partners and prefetch 'parent_id' on them
all_partners = self.with_context(active_test=False).search_fetch(
[('id', 'child_of', self.ids)],
['parent_id'],
)
partners = self.browse()
for group in sale_order_groups:
partner = self.browse(group['partner_id'][0])
sale_order_groups = self.env['sale.order']._read_group(
domain=Domain.AND([self._get_sale_order_domain_count(), [('partner_id', 'in', all_partners.ids)]]),
groupby=['partner_id'], aggregates=['__count']
)
self_ids = set(self._ids)
for partner, count in sale_order_groups:
while partner:
if partner in self:
partner.sale_order_count += group['partner_id_count']
partners |= partner
if partner.id in self_ids:
partner.sale_order_count += count
partner = partner.parent_id
(self - partners).sale_order_count = 0
def _compute_application_statistics_hook(self):
data_list = super()._compute_application_statistics_hook()
if not self.env.user.has_group('sales_team.group_sale_salesman'):
return data_list
for partner in self.filtered('sale_order_count'):
data_list[partner.id].append(
{'iconClass': 'fa-usd', 'value': partner.sale_order_count, 'label': self.env._('Sale Orders'), 'tagClass': 'o_tag_color_2'}
)
return data_list
def _has_order(self, partner_domain):
self.ensure_one()
sale_order = self.env['sale.order'].sudo().search(
Domain.AND([
partner_domain,
[
('state', 'in', ('sent', 'sale')),
]
]),
limit=1,
)
return bool(sale_order)
def _can_edit_country(self):
""" Can't edit `country_id` if there is (non draft) issued SO. """
return super()._can_edit_country() and not self._has_order(
[
'|',
('partner_invoice_id', '=', self.id),
('partner_id', '=', self.id),
]
)
def can_edit_vat(self):
''' Can't edit `vat` if there is (non draft) issued SO. '''
can_edit_vat = super(ResPartner, self).can_edit_vat()
if not can_edit_vat:
return can_edit_vat
SaleOrder = self.env['sale.order']
has_so = SaleOrder.sudo().search([
('partner_id', 'child_of', self.commercial_partner_id.id),
('state', 'in', ['sent', 'sale', 'done'])
], limit=1)
return can_edit_vat and not bool(has_so)
""" Can't edit `vat` if there is (non draft) issued SO. """
return super().can_edit_vat() and not self._has_order(
[('partner_id', 'child_of', self.commercial_partner_id.id)]
)
def action_view_sale_order(self):
action = self.env['ir.actions.act_window']._for_xml_id('sale.act_res_partner_2_sale_order')
all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
action["domain"] = [("partner_id", "in", all_child.ids)]
return action
def _compute_credit_to_invoice(self):
# EXTENDS 'account'
super()._compute_credit_to_invoice()
if not (commercial_partners := self.commercial_partner_id & self):
return # nothing to compute
company = self.env.company
if not company.account_use_credit_limit:
return
sale_orders = self.env['sale.order'].search([
('company_id', '=', company.id),
('partner_invoice_id', 'any', [
('commercial_partner_id', 'in', commercial_partners.ids),
]),
('order_line', 'any', [('untaxed_amount_to_invoice', '>', 0)]),
('state', '=', 'sale'),
])
for (partner, currency), orders in sale_orders.grouped(
lambda so: (so.partner_invoice_id, so.currency_id),
).items():
amount_to_invoice_sum = sum(orders.mapped('amount_to_invoice'))
credit_company_currency = currency._convert(
amount_to_invoice_sum,
company.currency_id,
company,
fields.Date.context_today(self),
)
partner.commercial_partner_id.credit_to_invoice += credit_company_currency

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.tools import SQL
class UtmCampaign(models.Model):
_inherit = 'utm.campaign'
@ -11,21 +12,23 @@ class UtmCampaign(models.Model):
compute="_compute_quotation_count", compute_sudo=True, groups='sales_team.group_sale_salesman')
invoiced_amount = fields.Integer(string="Revenues generated by the campaign",
compute="_compute_sale_invoiced_amount", compute_sudo=True, groups='sales_team.group_sale_salesman')
company_id = fields.Many2one('res.company', string='Company', readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company)
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string='Currency')
def _compute_quotation_count(self):
quotation_data = self.env['sale.order']._read_group([
('campaign_id', 'in', self.ids)],
['campaign_id'], ['campaign_id'])
data_map = {datum['campaign_id'][0]: datum['campaign_id_count'] for datum in quotation_data}
['campaign_id'], ['__count'])
data_map = {campaign.id: count for campaign, count in quotation_data}
for campaign in self:
campaign.quotation_count = data_map.get(campaign.id, 0)
def _compute_sale_invoiced_amount(self):
self.env['account.move.line'].flush_model(['balance', 'move_id', 'account_id', 'display_type'])
self.env['account.move'].flush_model(['state', 'campaign_id', 'move_type'])
query = """SELECT move.campaign_id, -SUM(line.balance) as price_subtotal
if self.ids:
self.env['account.move.line'].flush_model(['balance', 'move_id', 'account_id', 'display_type'])
self.env['account.move'].flush_model(['state', 'campaign_id', 'move_type'])
query_res = self.env.execute_query_dict(SQL(
""" SELECT move.campaign_id, -SUM(line.balance) as price_subtotal
FROM account_move_line line
INNER JOIN account_move move ON line.move_id = move.id
WHERE move.state not in ('draft', 'cancel')
@ -33,11 +36,11 @@ class UtmCampaign(models.Model):
AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')
AND line.account_id IS NOT NULL
AND line.display_type = 'product'
GROUP BY move.campaign_id
"""
self._cr.execute(query, [tuple(self.ids)])
query_res = self._cr.dictfetchall()
GROUP BY move.campaign_id """,
tuple(self.ids),
))
else:
query_res = []
campaigns = self.browse()
for datum in query_res: