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,17 @@
# -*- 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 crm_team
from . import payment_provider
from . import payment_transaction
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
from . import utm_campaign

View file

@ -0,0 +1,156 @@
# -*- 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
class AccountMove(models.Model):
_name = 'account.move'
_inherit = ['account.move', 'utm.mixin']
team_id = fields.Many2one(
'crm.team', string='Sales Team',
compute='_compute_team_id', store=True, readonly=False,
ondelete="set null", tracking=True,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
# UTMs - enforcing the fact that we want to 'set null' when relation is unlinked
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')
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'))
res = super(AccountMove, self).unlink()
if downpayment_lines:
downpayment_lines.unlink()
return res
@api.depends('invoice_user_id')
def _compute_team_id(self):
applicable_moves = self.filtered(
lambda move:
move.is_sale_document(include_receipts=True)
)
for ((user_id, company_id), moves) in groupby(
applicable_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]
)._get_default_team_id(
user_id=user_id,
)
@api.depends('line_ids.sale_line_ids')
def _compute_origin_so_count(self):
for move in self:
move.sale_order_count = len(move.line_ids.sale_line_ids.order_id)
def _reverse_moves(self, default_values_list=None, cancel=False):
# OVERRIDE
if not default_values_list:
default_values_list = [{} for move in self]
for move, default_values in zip(self, default_values_list):
default_values.update({
'campaign_id': move.campaign_id.id,
'medium_id': move.medium_id.id,
'source_id': move.source_id.id,
})
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
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)
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
return res
def button_draft(self):
res = super().button_draft()
self.line_ids.filtered('is_downpayment').sale_line_ids.filtered(
lambda sol: not sol.display_type)._compute_name()
return res
def button_cancel(self):
res = super().button_cancel()
self.line_ids.filtered('is_downpayment').sale_line_ids.filtered(
lambda sol: not sol.display_type)._compute_name()
return res
def _post(self, soft=True):
# OVERRIDE
# Auto-reconcile the invoice with payments coming from transactions.
# It's useful when you have a "paid" sale order (using a payment transaction) and you invoice it later.
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)
for line in move_lines:
invoice.js_assign_outstanding_line(line.id)
return posted
def _invoice_paid_hook(self):
# OVERRIDE
res = super(AccountMove, self)._invoice_paid_hook()
todo = set()
for invoice in self.filtered(lambda move: move.is_invoice()):
for line in invoice.invoice_line_ids:
for sale_line in line.sale_line_ids:
todo.add((sale_line.order_id, invoice.name))
for (order, name) in todo:
order.message_post(body=_("Invoice %s paid", name))
return res
def _action_invoice_ready_to_be_sent(self):
# OVERRIDE
# Make sure the send invoice CRON is called when an invoice becomes ready to be sent by mail.
res = super()._action_invoice_ready_to_be_sent()
send_invoice_cron = self.env.ref('sale.send_invoice_cron', raise_if_not_found=False)
if send_invoice_cron:
send_invoice_cron._trigger()
return res
def action_view_source_sale_orders(self):
self.ensure_one()
source_orders = self.line_ids.sale_line_ids.order_id
result = self.env['ir.actions.act_window']._for_xml_id('sale.action_orders')
if len(source_orders) > 1:
result['domain'] = [('id', 'in', source_orders.ids)]
elif len(source_orders) == 1:
result['views'] = [(self.env.ref('sale.view_order_form', False).id, 'form')]
result['res_id'] = source_orders.id
else:
result = {'type': 'ir.actions.act_window_close'}
return result
def _is_downpayment(self):
# 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

View file

@ -0,0 +1,213 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_compare, float_is_zero
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
is_downpayment = fields.Boolean()
sale_line_ids = fields.Many2many(
'sale.order.line',
'sale_order_line_invoice_rel',
'invoice_line_id', 'order_line_id',
string='Sales Order Lines', readonly=True, copy=False)
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)
values['sale_line_ids'] = [(6, None, self.sale_line_ids.ids)]
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()
# 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']
if len(values_list) > 0:
for index, move_line in enumerate(self):
values = values_list[index]
if 'so_line' not in values:
if move_line._sale_can_be_reinvoice():
move_to_reinvoice |= move_line
# insert the sale line in the create values of the analytic entries
if move_to_reinvoice.filtered(lambda aml: not aml.move_id.reversed_entry_id and aml.product_id): # only if the move line is not a reversal one
map_sale_line_per_move = move_to_reinvoice._sale_create_reinvoice_sale_line()
for values in values_list:
sale_line = map_sale_line_per_move.get(values.get('move_line_id'))
if sale_line:
values['so_line'] = sale_line.id
return values_list
def _sale_can_be_reinvoice(self):
""" determine if the generated analytic line should be reinvoiced or not.
For Vendor Bill flow, if the product has a 'erinvoice policy' and is a cost, then we will find the SO on which reinvoice the AAL
"""
self.ensure_one()
if self.sale_line_ids:
return False
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
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):
sale_order_map = self._sale_determine_order()
sale_line_values_to_create = [] # the list of creation values of sale line to create.
existing_sale_line_cache = {} # in the sales_price-delivery case, we can reuse the same sale line. This cache will avoid doing a search each time the case happen
# `map_move_sale_line` is map where
# - key is the move line identifier
# - value is either a sale.order.line record (existing case), or an integer representing the index of the sale line to create in
# the `sale_line_values_to_create` (not existing case, which will happen more often than the first one).
map_move_sale_line = {}
for move_line in self:
sale_order = sale_order_map.get(move_line.id)
# no reinvoice as no sales order was found
if not sale_order:
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))
price = move_line._sale_get_invoice_price(sale_order)
# find the existing sale.line or keep its creation values to process this in batch
sale_line = None
if (
move_line.product_id.expense_policy == 'sales_price'
and move_line.product_id.invoice_policy == 'delivery'
and not self.env.context.get('force_split_lines')
):
# for those case only, we can try to reuse one
map_entry_key = (sale_order.id, move_line.product_id.id, price) # cache entry to limit the call to search
sale_line = existing_sale_line_cache.get(map_entry_key)
if sale_line: # already search, so reuse it. sale_line can be sale.order.line record or index of a "to create values" in `sale_line_values_to_create`
map_move_sale_line[move_line.id] = sale_line
existing_sale_line_cache[map_entry_key] = sale_line
else: # search for existing sale line
sale_line = self.env['sale.order.line'].search([
('order_id', '=', sale_order.id),
('price_unit', '=', price),
('product_id', '=', move_line.product_id.id),
('is_expense', '=', True),
], limit=1)
if sale_line: # found existing one, so keep the browse record
map_move_sale_line[move_line.id] = existing_sale_line_cache[map_entry_key] = sale_line
else: # should be create, so use the index of creation values instead of browse record
# save value to create it
sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price))
# store it in the cache of existing ones
existing_sale_line_cache[map_entry_key] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
# store it in the map_move_sale_line map
map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
else: # save its value to create it anyway
sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price))
map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
# create the sale lines in batch
new_sale_lines = self.env['sale.order.line'].create(sale_line_values_to_create)
# build result map by replacing index with newly created record of sale.order.line
result = {}
for move_line_id, unknown_sale_line in map_move_sale_line.items():
if isinstance(unknown_sale_line, int): # index of newly created sale line
result[move_line_id] = new_sale_lines[unknown_sale_line]
elif isinstance(unknown_sale_line, models.BaseModel): # already record of sale.order.line
result[move_line_id] = unknown_sale_line
return result
def _sale_determine_order(self):
""" 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
def _sale_prepare_sale_line_values(self, order, price):
""" Generate the sale.line creation value from the current move line """
self.ensure_one()
last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
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)
taxes = fpos.map_tax(product_taxes)
return {
'order_id': order.id,
'name': self.name,
'sequence': last_sequence,
'price_unit': price,
'tax_id': [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,
'is_expense': True,
}
def _sale_get_invoice_price(self, order):
""" Based on the current move line, compute the price to reinvoice the analytic line that is going to be created (so the
price of the sale line).
"""
self.ensure_one()
unit_amount = self.quantity
amount = (self.credit or 0.0) - (self.debit or 0.0)
if self.product_id.expense_policy == 'sales_price':
return order.pricelist_id._get_product_price(
self.product_id,
1.0,
uom=self.product_uom_id,
date=order.date_order,
)
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_is_zero(unit_amount, precision_digits=uom_precision_digits):
return 0.0
# Prevent unnecessary currency conversion that could be impacted by exchange rate
# fluctuations
if self.company_id.currency_id and amount and self.company_id.currency_id == order.currency_id:
return self.company_id.currency_id.round(abs(amount / unit_amount))
price_unit = abs(amount / unit_amount)
currency_id = self.company_id.currency_id
if currency_id and currency_id != order.currency_id:
price_unit = currency_id._convert(price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today())
return price_unit
def _get_downpayment_lines(self):
# OVERRIDE
return self.sale_line_ids.filtered('is_downpayment').invoice_lines.filtered(lambda line: line.move_id._is_downpayment())

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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())
class AccountAnalyticApplicability(models.Model):
_inherit = 'account.analytic.applicability'
_description = "Analytic Plan's Applicabilities"
business_domain = fields.Selection(
selection_add=[
('sale_order', 'Sale Order'),
],
ondelete={'sale_order': 'cascade'},
)

View file

@ -0,0 +1,169 @@
# -*- 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 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,
help="Invoice revenue for the current month. This is the amount the sales "
"channel has invoiced this month. It is used to compute the progression ratio "
"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)
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
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)
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}
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():
self.dashboard_button_name = _("Sales Analysis")
def action_primary_channel_button(self):
if self._in_sale_scope():
return self.env["ir.actions.actions"]._for_xml_id("sale.action_order_report_so_salesteam")
return super().action_primary_channel_button()
def update_invoiced_target(self, value):
return self.write({'invoiced_target': round(float(value or 0))})
@api.ondelete(at_uninstall=False)
def _unlink_except_used_for_sales(self):
""" If more than 5 active SOs, we consider this team to be actively used.
5 is some random guess based on "user testing", aka more than testing
CRM feature and less than use it in real life use cases. """
SO_COUNT_TRIGGER = 5
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_name=team.name,
sale_order_count=team.sale_order_count
))

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 PaymentProvider(models.Model):
_inherit = 'payment.provider'
so_reference_type = fields.Selection(string='Communication',
selection=[
('so_name', 'Based on Document Reference'),
('partner', 'Based on Customer ID')], default='so_name',
help='You can set here the communication type that will appear on sales orders.'
'The communication will be given to the customer when they choose the payment method.')

View file

@ -0,0 +1,237 @@
# 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__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
sale_order_ids = fields.Many2many('sale.order', 'sale_order_transaction_rel', 'transaction_id', 'sale_order_id',
string='Sales Orders', copy=False, readonly=True)
sale_order_ids_nbr = fields.Integer(compute='_compute_sale_order_ids_nbr', string='# of Sales Orders')
def _compute_sale_order_reference(self, order):
self.ensure_one()
if self.provider_id.so_reference_type == 'so_name':
order_reference = order.name
else:
# 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'))
invoice_journal = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', self.env.company.id)], limit=1)
if invoice_journal:
order_reference = invoice_journal._process_reference_for_sale_order(order_reference)
return order_reference
@api.depends('sale_order_ids')
def _compute_sale_order_ids_nbr(self):
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.
:param str state_message: The reason for which the transaction is set in 'pending' state.
:return: updated transactions.
:rtype: `payment.transaction` recordset.
"""
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'])
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()
return txs_to_process
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.
: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.
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),
},
)
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.
Note: self.ensure_one()
:param str message: The message to be logged
: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()
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:
tx = tx.with_company(tx.company_id).with_context(
company_id=tx.company_id.id,
)
invoice_to_send = tx.invoice_ids.filtered(
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',
)
def _cron_send_invoice(self):
"""
Cron to send invoice that where not ready to be send directly after posting
"""
if not self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'):
return
# No need to retrieve old transactions
retry_limit_date = datetime.now() - relativedelta.relativedelta(days=2)
# Retrieve all transactions matching the criteria for post-processing
self.search([
('state', '=', 'done'),
('is_post_processed', '=', True),
('invoice_ids', 'in', self.env['account.move']._search([
('is_move_sent', '=', False),
('state', '=', 'posted'),
])),
('sale_order_ids.state', 'in', ('sale', 'done')),
('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'))
if confirmed_orders:
confirmed_orders._force_lines_to_invoice_policy_order()
invoices = confirmed_orders.with_context(
raise_if_nothing_to_invoice=False
)._create_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:
invoice._portal_ensure_token()
tx.invoice_ids = [Command.set(invoices.ids)]
@api.model
def _compute_reference_prefix(self, provider_code, 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), ...], ...}.
:return: The computed reference prefix if order ids are found, the one of `super` otherwise
:rtype: str
"""
command_list = values.get('sale_order_ids')
if command_list:
# Extract sales order id(s) from the X2M commands
order_ids = self._fields['sale_order_ids'].convert_to_cache(command_list, self)
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)
def action_view_sales_orders(self):
action = {
'name': _('Sales Order(s)'),
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'target': 'current',
}
sale_order_ids = self.sale_order_ids.ids
if len(sale_order_ids) == 1:
action['res_id'] = sale_order_ids[0]
action['view_mode'] = 'form'
else:
action['view_mode'] = 'tree,form'
action['domain'] = [('id', 'in', sale_order_ids)]
return action

View file

@ -0,0 +1,86 @@
# -*- 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
class ProductProduct(models.Model):
_inherit = 'product.product'
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
def _compute_sales_count(self):
r = {}
self.sales_count = 0
if not self.user_has_groups('sales_team.group_sale_salesman'):
return r
date_from = fields.Datetime.to_string(fields.datetime.combine(fields.datetime.now() - timedelta(days=365),
time.min))
done_states = self.env['sale.report']._get_done_states()
domain = [
('state', 'in', done_states),
('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 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)
return r
@api.onchange('type')
def _onchange_type(self):
if self._origin and self.sales_count > 0:
return {'warning': {
'title': _("Warning"),
'message': _("You cannot change the product's type because it is already used in sales orders.")
}}
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'),
'search_default_Sales': 1,
'active_model': 'sale.report',
'search_default_filter_order_date': 1,
}
return action
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]
return super(ProductProduct, self - self.browse(linked_product_ids))._filter_to_unlink()
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')
_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")

View file

@ -0,0 +1,329 @@
# -*- 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.exceptions import ValidationError
from odoo.tools.float_utils import float_round
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
_inherit = 'product.template'
service_type = fields.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')
expense_policy = fields.Selection(
[('no', 'No'),
('cost', 'At cost'),
('sales_price', 'Sales price')
], string='Re-Invoice Expenses', 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')
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.')
def _compute_visible_qty_configurator(self):
for product_template in self:
product_template.visible_qty_configurator = True
@api.depends('name')
def _compute_visible_expense_policy(self):
visibility = self.user_has_groups('analytic.group_analytic_accounting')
for product_template in self:
product_template.visible_expense_policy = visibility
@api.depends('sale_ok')
def _compute_expense_policy(self):
self.filtered(lambda t: not t.sale_ok).expense_policy = 'no'
@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)
@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'])
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
for target_company, products in products_by_company.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']
)
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'
' %(company)s because they have already been used in quotations or '
'sales orders in another company:\n%(used_products)s\n'
'You can archive these products and recreate them '
'with your company restriction instead, or leave them as '
'shared product.', company=target_company.name, used_products=', '.join(used_products)))
def action_view_sales(self):
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_model': 'sale.report',
'search_default_Sales': 1,
'search_default_filter_order_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()
if self._origin and self.sales_count > 0:
res['warning'] = {
'title': _("Warning"),
'message': _("You cannot change the product's type because it is already used in sales orders.")
}
return res
@api.depends('type')
def _compute_service_type(self):
self.filtered(lambda t: t.type == 'consu' or not t.service_type).service_type = 'manual'
@api.depends('type')
def _compute_invoice_policy(self):
self.filtered(lambda t: t.type == 'consu' or not t.invoice_policy).invoice_policy = 'order'
@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'):
return [{
'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.
Note: this method does not take into account whether the combination is
actually possible.
:param combination: recordset of `product.template.attribute.value`
: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.
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
: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
"""
self.ensure_one()
# get the name before the change of context to benefit from prefetch
display_name = self.display_name
display_image = True
quantity = self.env.context.get('quantity', add_qty)
product_template = self
combination = combination or product_template.env['product.template.attribute.value']
if not product_id and not combination and not only_template:
combination = product_template._get_first_possible_combination(parent_combination)
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):
"""
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
"""
return self.sale_ok
def _is_add_to_cart_possible(self, parent_combination=None):
"""
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

View file

@ -0,0 +1,135 @@
# -*- 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
class ResCompany(models.Model):
_inherit = "res.company"
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)")
# 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')
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")
@api.model
def action_close_sale_quotation_onboarding(self):
""" Mark the onboarding panel as closed. """
self.env.company.sale_quotation_onboarding_state = 'closed'
@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.')]

View file

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

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.osv import expression
class ResPartner(models.Model):
_inherit = 'res.partner'
sale_order_count = fields.Integer(compute='_compute_sale_order_count', string='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
def _get_sale_order_domain_count(self):
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)])
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']
)
partners = self.browse()
for group in sale_order_groups:
partner = self.browse(group['partner_id'][0])
while partner:
if partner in self:
partner.sale_order_count += group['partner_id_count']
partners |= partner
partner = partner.parent_id
(self - partners).sale_order_count = 0
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)
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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class UtmCampaign(models.Model):
_inherit = 'utm.campaign'
_description = 'UTM Campaign'
quotation_count = fields.Integer('Quotation Count',
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)
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}
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
FROM account_move_line line
INNER JOIN account_move move ON line.move_id = move.id
WHERE move.state not in ('draft', 'cancel')
AND move.campaign_id IN %s
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()
campaigns = self.browse()
for datum in query_res:
campaign = self.browse(datum['campaign_id'])
campaign.invoiced_amount = datum['price_subtotal']
campaigns |= campaign
for campaign in (self - campaigns):
campaign.invoiced_amount = 0
def action_redirect_to_quotations(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_quotations_with_onboarding")
action['domain'] = [('campaign_id', '=', self.id)]
action['context'] = {'default_campaign_id': self.id}
return action
def action_redirect_to_invoiced(self):
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
invoices = self.env['account.move'].search([('campaign_id', '=', self.id)])
action['context'] = {
'create': False,
'edit': False,
'view_no_maturity': True
}
action['domain'] = [
('id', 'in', invoices.ids),
('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')),
('state', 'not in', ['draft', 'cancel'])
]
return action