mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 11:12:04 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
10
odoo-bringout-oca-ocb-sale/sale/models/chart_template.py
Normal file
10
odoo-bringout-oca-ocb-sale/sale/models/chart_template.py
Normal 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
|
||||
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
56
odoo-bringout-oca-ocb-sale/sale/models/ir_actions_report.py
Normal file
56
odoo-bringout-oca-ocb-sale/sale/models/ir_actions_report.py
Normal 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',
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
odoo-bringout-oca-ocb-sale/sale/models/product_document.py
Normal file
25
odoo-bringout-oca-ocb-sale/sale/models/product_document.py
Normal 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',
|
||||
)
|
||||
|
|
@ -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'
|
||||
|
|
@ -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.',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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."))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue