mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 05:52:06 +02:00
Initial commit: Sale packages
This commit is contained in:
commit
14e3d26998
6469 changed files with 2479670 additions and 0 deletions
17
odoo-bringout-oca-ocb-sale/sale/models/__init__.py
Normal file
17
odoo-bringout-oca-ocb-sale/sale/models/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import analytic
|
||||
from . import account_move
|
||||
from . import account_move_line
|
||||
from . import crm_team
|
||||
from . import payment_provider
|
||||
from . import payment_transaction
|
||||
from . import product_product
|
||||
from . import product_template
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import utm_campaign
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
156
odoo-bringout-oca-ocb-sale/sale/models/account_move.py
Normal file
156
odoo-bringout-oca-ocb-sale/sale/models/account_move.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import groupby
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_name = 'account.move'
|
||||
_inherit = ['account.move', 'utm.mixin']
|
||||
|
||||
team_id = fields.Many2one(
|
||||
'crm.team', string='Sales Team',
|
||||
compute='_compute_team_id', store=True, readonly=False,
|
||||
ondelete="set null", tracking=True,
|
||||
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
|
||||
|
||||
# UTMs - enforcing the fact that we want to 'set null' when relation is unlinked
|
||||
campaign_id = fields.Many2one(ondelete='set null')
|
||||
medium_id = fields.Many2one(ondelete='set null')
|
||||
source_id = fields.Many2one(ondelete='set null')
|
||||
sale_order_count = fields.Integer(compute="_compute_origin_so_count", string='Sale Order Count')
|
||||
|
||||
def unlink(self):
|
||||
downpayment_lines = self.mapped('line_ids.sale_line_ids').filtered(lambda line: line.is_downpayment and line.invoice_lines <= self.mapped('line_ids'))
|
||||
res = super(AccountMove, self).unlink()
|
||||
if downpayment_lines:
|
||||
downpayment_lines.unlink()
|
||||
return res
|
||||
|
||||
@api.depends('invoice_user_id')
|
||||
def _compute_team_id(self):
|
||||
applicable_moves = self.filtered(
|
||||
lambda move:
|
||||
move.is_sale_document(include_receipts=True)
|
||||
)
|
||||
|
||||
for ((user_id, company_id), moves) in groupby(
|
||||
applicable_moves,
|
||||
key=lambda m: (m.invoice_user_id.id, m.company_id.id)
|
||||
):
|
||||
self.env['account.move'].concat(*moves).team_id = self.env['crm.team'].with_context(
|
||||
allowed_company_ids=[company_id]
|
||||
)._get_default_team_id(
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@api.depends('line_ids.sale_line_ids')
|
||||
def _compute_origin_so_count(self):
|
||||
for move in self:
|
||||
move.sale_order_count = len(move.line_ids.sale_line_ids.order_id)
|
||||
|
||||
def _reverse_moves(self, default_values_list=None, cancel=False):
|
||||
# OVERRIDE
|
||||
if not default_values_list:
|
||||
default_values_list = [{} for move in self]
|
||||
for move, default_values in zip(self, default_values_list):
|
||||
default_values.update({
|
||||
'campaign_id': move.campaign_id.id,
|
||||
'medium_id': move.medium_id.id,
|
||||
'source_id': move.source_id.id,
|
||||
})
|
||||
return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel)
|
||||
|
||||
def action_post(self):
|
||||
#inherit of the function from account.move to validate a new tax and the priceunit of a downpayment
|
||||
res = super(AccountMove, self).action_post()
|
||||
down_payment_lines = self.line_ids.filtered('is_downpayment')
|
||||
for line in down_payment_lines:
|
||||
|
||||
if not line.sale_line_ids.display_type:
|
||||
line.sale_line_ids._compute_name()
|
||||
|
||||
downpayment_lines = self.line_ids.sale_line_ids.filtered(lambda l: l.is_downpayment and not l.display_type)
|
||||
other_so_lines = downpayment_lines.order_id.order_line - downpayment_lines
|
||||
real_invoices = set(other_so_lines.invoice_lines.move_id)
|
||||
for dpl in downpayment_lines:
|
||||
try:
|
||||
dpl.price_unit = dpl._get_downpayment_line_price_unit(real_invoices)
|
||||
dpl.tax_id = dpl.invoice_lines.tax_ids
|
||||
except UserError:
|
||||
# a UserError here means the SO was locked, which prevents changing the taxes
|
||||
# just ignore the error - this is a nice to have feature and should not be blocking
|
||||
pass
|
||||
return res
|
||||
|
||||
def button_draft(self):
|
||||
res = super().button_draft()
|
||||
|
||||
self.line_ids.filtered('is_downpayment').sale_line_ids.filtered(
|
||||
lambda sol: not sol.display_type)._compute_name()
|
||||
|
||||
return res
|
||||
|
||||
def button_cancel(self):
|
||||
res = super().button_cancel()
|
||||
|
||||
self.line_ids.filtered('is_downpayment').sale_line_ids.filtered(
|
||||
lambda sol: not sol.display_type)._compute_name()
|
||||
|
||||
return res
|
||||
|
||||
def _post(self, soft=True):
|
||||
# OVERRIDE
|
||||
# Auto-reconcile the invoice with payments coming from transactions.
|
||||
# It's useful when you have a "paid" sale order (using a payment transaction) and you invoice it later.
|
||||
posted = super()._post(soft)
|
||||
|
||||
for invoice in posted.filtered(lambda move: move.is_invoice()):
|
||||
payments = invoice.mapped('transaction_ids.payment_id').filtered(lambda x: x.state == 'posted')
|
||||
move_lines = payments.line_ids.filtered(lambda line: line.account_type in ('asset_receivable', 'liability_payable') and not line.reconciled)
|
||||
for line in move_lines:
|
||||
invoice.js_assign_outstanding_line(line.id)
|
||||
return posted
|
||||
|
||||
def _invoice_paid_hook(self):
|
||||
# OVERRIDE
|
||||
res = super(AccountMove, self)._invoice_paid_hook()
|
||||
todo = set()
|
||||
for invoice in self.filtered(lambda move: move.is_invoice()):
|
||||
for line in invoice.invoice_line_ids:
|
||||
for sale_line in line.sale_line_ids:
|
||||
todo.add((sale_line.order_id, invoice.name))
|
||||
for (order, name) in todo:
|
||||
order.message_post(body=_("Invoice %s paid", name))
|
||||
return res
|
||||
|
||||
def _action_invoice_ready_to_be_sent(self):
|
||||
# OVERRIDE
|
||||
# Make sure the send invoice CRON is called when an invoice becomes ready to be sent by mail.
|
||||
res = super()._action_invoice_ready_to_be_sent()
|
||||
|
||||
send_invoice_cron = self.env.ref('sale.send_invoice_cron', raise_if_not_found=False)
|
||||
if send_invoice_cron:
|
||||
send_invoice_cron._trigger()
|
||||
|
||||
return res
|
||||
|
||||
def action_view_source_sale_orders(self):
|
||||
self.ensure_one()
|
||||
source_orders = self.line_ids.sale_line_ids.order_id
|
||||
result = self.env['ir.actions.act_window']._for_xml_id('sale.action_orders')
|
||||
if len(source_orders) > 1:
|
||||
result['domain'] = [('id', 'in', source_orders.ids)]
|
||||
elif len(source_orders) == 1:
|
||||
result['views'] = [(self.env.ref('sale.view_order_form', False).id, 'form')]
|
||||
result['res_id'] = source_orders.id
|
||||
else:
|
||||
result = {'type': 'ir.actions.act_window_close'}
|
||||
return result
|
||||
|
||||
def _is_downpayment(self):
|
||||
# OVERRIDE
|
||||
self.ensure_one()
|
||||
return self.line_ids.sale_line_ids and all(sale_line.is_downpayment for sale_line in self.line_ids.sale_line_ids) or False
|
||||
213
odoo-bringout-oca-ocb-sale/sale/models/account_move_line.py
Normal file
213
odoo-bringout-oca-ocb-sale/sale/models/account_move_line.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare, float_is_zero
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
is_downpayment = fields.Boolean()
|
||||
sale_line_ids = fields.Many2many(
|
||||
'sale.order.line',
|
||||
'sale_order_line_invoice_rel',
|
||||
'invoice_line_id', 'order_line_id',
|
||||
string='Sales Order Lines', readonly=True, copy=False)
|
||||
|
||||
def _copy_data_extend_business_fields(self, values):
|
||||
# OVERRIDE to copy the 'sale_line_ids' field as well.
|
||||
super(AccountMoveLine, self)._copy_data_extend_business_fields(values)
|
||||
values['sale_line_ids'] = [(6, None, self.sale_line_ids.ids)]
|
||||
|
||||
def _prepare_analytic_lines(self):
|
||||
""" Note: This method is called only on the move.line that having an analytic distribution, and
|
||||
so that should create analytic entries.
|
||||
"""
|
||||
values_list = super(AccountMoveLine, self)._prepare_analytic_lines()
|
||||
|
||||
# filter the move lines that can be reinvoiced: a cost (negative amount) analytic line without SO line but with a product can be reinvoiced
|
||||
move_to_reinvoice = self.env['account.move.line']
|
||||
if len(values_list) > 0:
|
||||
for index, move_line in enumerate(self):
|
||||
values = values_list[index]
|
||||
if 'so_line' not in values:
|
||||
if move_line._sale_can_be_reinvoice():
|
||||
move_to_reinvoice |= move_line
|
||||
|
||||
# insert the sale line in the create values of the analytic entries
|
||||
if move_to_reinvoice.filtered(lambda aml: not aml.move_id.reversed_entry_id and aml.product_id): # only if the move line is not a reversal one
|
||||
map_sale_line_per_move = move_to_reinvoice._sale_create_reinvoice_sale_line()
|
||||
for values in values_list:
|
||||
sale_line = map_sale_line_per_move.get(values.get('move_line_id'))
|
||||
if sale_line:
|
||||
values['so_line'] = sale_line.id
|
||||
|
||||
return values_list
|
||||
|
||||
def _sale_can_be_reinvoice(self):
|
||||
""" determine if the generated analytic line should be reinvoiced or not.
|
||||
For Vendor Bill flow, if the product has a 'erinvoice policy' and is a cost, then we will find the SO on which reinvoice the AAL
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.sale_line_ids:
|
||||
return False
|
||||
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
return float_compare(self.credit or 0.0, self.debit or 0.0, precision_digits=uom_precision_digits) != 1 and self.product_id.expense_policy not in [False, 'no']
|
||||
|
||||
def _sale_create_reinvoice_sale_line(self):
|
||||
|
||||
sale_order_map = self._sale_determine_order()
|
||||
|
||||
sale_line_values_to_create = [] # the list of creation values of sale line to create.
|
||||
existing_sale_line_cache = {} # in the sales_price-delivery case, we can reuse the same sale line. This cache will avoid doing a search each time the case happen
|
||||
# `map_move_sale_line` is map where
|
||||
# - key is the move line identifier
|
||||
# - value is either a sale.order.line record (existing case), or an integer representing the index of the sale line to create in
|
||||
# the `sale_line_values_to_create` (not existing case, which will happen more often than the first one).
|
||||
map_move_sale_line = {}
|
||||
|
||||
for move_line in self:
|
||||
sale_order = sale_order_map.get(move_line.id)
|
||||
|
||||
# no reinvoice as no sales order was found
|
||||
if not sale_order:
|
||||
continue
|
||||
|
||||
# raise if the sale order is not currently open
|
||||
if sale_order.state != 'sale':
|
||||
message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.')
|
||||
messages = {
|
||||
'draft': message_unconfirmed,
|
||||
'sent': message_unconfirmed,
|
||||
'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'),
|
||||
'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'),
|
||||
}
|
||||
raise UserError(messages[sale_order.state] % (sale_order.name, sale_order.analytic_account_id.name))
|
||||
|
||||
price = move_line._sale_get_invoice_price(sale_order)
|
||||
|
||||
# find the existing sale.line or keep its creation values to process this in batch
|
||||
sale_line = None
|
||||
if (
|
||||
move_line.product_id.expense_policy == 'sales_price'
|
||||
and move_line.product_id.invoice_policy == 'delivery'
|
||||
and not self.env.context.get('force_split_lines')
|
||||
):
|
||||
# for those case only, we can try to reuse one
|
||||
map_entry_key = (sale_order.id, move_line.product_id.id, price) # cache entry to limit the call to search
|
||||
sale_line = existing_sale_line_cache.get(map_entry_key)
|
||||
if sale_line: # already search, so reuse it. sale_line can be sale.order.line record or index of a "to create values" in `sale_line_values_to_create`
|
||||
map_move_sale_line[move_line.id] = sale_line
|
||||
existing_sale_line_cache[map_entry_key] = sale_line
|
||||
else: # search for existing sale line
|
||||
sale_line = self.env['sale.order.line'].search([
|
||||
('order_id', '=', sale_order.id),
|
||||
('price_unit', '=', price),
|
||||
('product_id', '=', move_line.product_id.id),
|
||||
('is_expense', '=', True),
|
||||
], limit=1)
|
||||
if sale_line: # found existing one, so keep the browse record
|
||||
map_move_sale_line[move_line.id] = existing_sale_line_cache[map_entry_key] = sale_line
|
||||
else: # should be create, so use the index of creation values instead of browse record
|
||||
# save value to create it
|
||||
sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price))
|
||||
# store it in the cache of existing ones
|
||||
existing_sale_line_cache[map_entry_key] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
|
||||
# store it in the map_move_sale_line map
|
||||
map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
|
||||
|
||||
else: # save its value to create it anyway
|
||||
sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price))
|
||||
map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line
|
||||
|
||||
# create the sale lines in batch
|
||||
new_sale_lines = self.env['sale.order.line'].create(sale_line_values_to_create)
|
||||
|
||||
# build result map by replacing index with newly created record of sale.order.line
|
||||
result = {}
|
||||
for move_line_id, unknown_sale_line in map_move_sale_line.items():
|
||||
if isinstance(unknown_sale_line, int): # index of newly created sale line
|
||||
result[move_line_id] = new_sale_lines[unknown_sale_line]
|
||||
elif isinstance(unknown_sale_line, models.BaseModel): # already record of sale.order.line
|
||||
result[move_line_id] = unknown_sale_line
|
||||
return result
|
||||
|
||||
def _sale_determine_order(self):
|
||||
""" Get the mapping of move.line with the sale.order record on which its analytic entries should be reinvoiced
|
||||
:return a dict where key is the move line id, and value is sale.order record (or None).
|
||||
"""
|
||||
mapping = {}
|
||||
for move_line in self:
|
||||
if move_line.analytic_distribution:
|
||||
distribution_json = move_line.analytic_distribution
|
||||
sale_order = self.env['sale.order'].search([('analytic_account_id', 'in', list(int(account_id) for account_id in distribution_json.keys())),
|
||||
('state', '=', 'sale')], order='create_date ASC', limit=1)
|
||||
if sale_order:
|
||||
mapping[move_line.id] = sale_order
|
||||
else:
|
||||
sale_order = self.env['sale.order'].search([('analytic_account_id', 'in', list(int(account_id) for account_id in distribution_json.keys()))], order='create_date ASC', limit=1)
|
||||
mapping[move_line.id] = sale_order
|
||||
|
||||
# map of AAL index with the SO on which it needs to be reinvoiced. Maybe be None if no SO found
|
||||
return mapping
|
||||
|
||||
def _sale_prepare_sale_line_values(self, order, price):
|
||||
""" Generate the sale.line creation value from the current move line """
|
||||
self.ensure_one()
|
||||
last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
|
||||
last_sequence = last_so_line.sequence + 1 if last_so_line else 100
|
||||
|
||||
fpos = order.fiscal_position_id or order.fiscal_position_id._get_fiscal_position(order.partner_id)
|
||||
product_taxes = self.product_id.taxes_id.filtered(lambda tax: tax.company_id == order.company_id)
|
||||
taxes = fpos.map_tax(product_taxes)
|
||||
|
||||
return {
|
||||
'order_id': order.id,
|
||||
'name': self.name,
|
||||
'sequence': last_sequence,
|
||||
'price_unit': price,
|
||||
'tax_id': [x.id for x in taxes],
|
||||
'discount': 0.0,
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom': self.product_uom_id.id,
|
||||
'product_uom_qty': 0.0,
|
||||
'is_expense': True,
|
||||
}
|
||||
|
||||
def _sale_get_invoice_price(self, order):
|
||||
""" Based on the current move line, compute the price to reinvoice the analytic line that is going to be created (so the
|
||||
price of the sale line).
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
unit_amount = self.quantity
|
||||
amount = (self.credit or 0.0) - (self.debit or 0.0)
|
||||
|
||||
if self.product_id.expense_policy == 'sales_price':
|
||||
return order.pricelist_id._get_product_price(
|
||||
self.product_id,
|
||||
1.0,
|
||||
uom=self.product_uom_id,
|
||||
date=order.date_order,
|
||||
)
|
||||
|
||||
uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
if float_is_zero(unit_amount, precision_digits=uom_precision_digits):
|
||||
return 0.0
|
||||
|
||||
# Prevent unnecessary currency conversion that could be impacted by exchange rate
|
||||
# fluctuations
|
||||
if self.company_id.currency_id and amount and self.company_id.currency_id == order.currency_id:
|
||||
return self.company_id.currency_id.round(abs(amount / unit_amount))
|
||||
|
||||
price_unit = abs(amount / unit_amount)
|
||||
currency_id = self.company_id.currency_id
|
||||
if currency_id and currency_id != order.currency_id:
|
||||
price_unit = currency_id._convert(price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today())
|
||||
return price_unit
|
||||
|
||||
def _get_downpayment_lines(self):
|
||||
# OVERRIDE
|
||||
return self.sale_line_ids.filtered('is_downpayment').invoice_lines.filtered(lambda line: line.move_id._is_downpayment())
|
||||
28
odoo-bringout-oca-ocb-sale/sale/models/analytic.py
Normal file
28
odoo-bringout-oca-ocb-sale/sale/models/analytic.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = "account.analytic.line"
|
||||
|
||||
def _default_sale_line_domain(self):
|
||||
""" This is only used for delivered quantity of SO line based on analytic line, and timesheet
|
||||
(see sale_timesheet). This can be override to allow further customization.
|
||||
"""
|
||||
return [('qty_delivered_method', '=', 'analytic')]
|
||||
|
||||
so_line = fields.Many2one('sale.order.line', string='Sales Order Item', domain=lambda self: self._default_sale_line_domain())
|
||||
|
||||
|
||||
class AccountAnalyticApplicability(models.Model):
|
||||
_inherit = 'account.analytic.applicability'
|
||||
_description = "Analytic Plan's Applicabilities"
|
||||
|
||||
business_domain = fields.Selection(
|
||||
selection_add=[
|
||||
('sale_order', 'Sale Order'),
|
||||
],
|
||||
ondelete={'sale_order': 'cascade'},
|
||||
)
|
||||
169
odoo-bringout-oca-ocb-sale/sale/models/crm_team.py
Normal file
169
odoo-bringout-oca-ocb-sale/sale/models/crm_team.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class CrmTeam(models.Model):
|
||||
_inherit = 'crm.team'
|
||||
|
||||
use_quotations = fields.Boolean(string='Quotations', help="Check this box if you send quotations to your customers rather than confirming orders straight away.")
|
||||
invoiced = fields.Float(
|
||||
compute='_compute_invoiced',
|
||||
string='Invoiced This Month', readonly=True,
|
||||
help="Invoice revenue for the current month. This is the amount the sales "
|
||||
"channel has invoiced this month. It is used to compute the progression ratio "
|
||||
"of the current and target revenue on the kanban view.")
|
||||
invoiced_target = fields.Float(
|
||||
string='Invoicing Target',
|
||||
help="Revenue target for the current month (untaxed total of confirmed invoices).")
|
||||
quotations_count = fields.Integer(
|
||||
compute='_compute_quotations_to_invoice',
|
||||
string='Number of quotations to invoice', readonly=True)
|
||||
quotations_amount = fields.Float(
|
||||
compute='_compute_quotations_to_invoice',
|
||||
string='Amount of quotations to invoice', readonly=True)
|
||||
sales_to_invoice_count = fields.Integer(
|
||||
compute='_compute_sales_to_invoice',
|
||||
string='Number of sales to invoice', readonly=True)
|
||||
sale_order_count = fields.Integer(compute='_compute_sale_order_count', string='# Sale Orders')
|
||||
|
||||
def _compute_quotations_to_invoice(self):
|
||||
query = self.env['sale.order']._where_calc([
|
||||
('team_id', 'in', self.ids),
|
||||
('state', 'in', ['draft', 'sent']),
|
||||
])
|
||||
self.env['sale.order']._apply_ir_rules(query, 'read')
|
||||
_, where_clause, where_clause_args = query.get_sql()
|
||||
select_query = """
|
||||
SELECT team_id, count(*), sum(amount_total /
|
||||
CASE COALESCE(currency_rate, 0)
|
||||
WHEN 0 THEN 1.0
|
||||
ELSE currency_rate
|
||||
END
|
||||
) as amount_total
|
||||
FROM sale_order
|
||||
WHERE %s
|
||||
GROUP BY team_id
|
||||
""" % where_clause
|
||||
self.env.cr.execute(select_query, where_clause_args)
|
||||
quotation_data = self.env.cr.dictfetchall()
|
||||
teams = self.browse()
|
||||
for datum in quotation_data:
|
||||
team = self.browse(datum['team_id'])
|
||||
team.quotations_amount = datum['amount_total']
|
||||
team.quotations_count = datum['count']
|
||||
teams |= team
|
||||
remaining = (self - teams)
|
||||
remaining.quotations_amount = 0
|
||||
remaining.quotations_count = 0
|
||||
|
||||
def _compute_sales_to_invoice(self):
|
||||
sale_order_data = self.env['sale.order']._read_group([
|
||||
('team_id', 'in', self.ids),
|
||||
('invoice_status','=','to invoice'),
|
||||
], ['team_id'], ['team_id'])
|
||||
data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data}
|
||||
for team in self:
|
||||
team.sales_to_invoice_count = data_map.get(team.id,0.0)
|
||||
|
||||
def _compute_invoiced(self):
|
||||
if not self:
|
||||
return
|
||||
|
||||
query = '''
|
||||
SELECT
|
||||
move.team_id AS team_id,
|
||||
SUM(move.amount_untaxed_signed) AS amount_untaxed_signed
|
||||
FROM account_move move
|
||||
WHERE move.move_type IN ('out_invoice', 'out_refund', 'out_receipt')
|
||||
AND move.payment_state IN ('in_payment', 'paid', 'reversed')
|
||||
AND move.state = 'posted'
|
||||
AND move.team_id IN %s
|
||||
AND move.date BETWEEN %s AND %s
|
||||
GROUP BY move.team_id
|
||||
'''
|
||||
today = fields.Date.today()
|
||||
params = [tuple(self.ids), fields.Date.to_string(today.replace(day=1)), fields.Date.to_string(today)]
|
||||
self._cr.execute(query, params)
|
||||
|
||||
data_map = dict((v[0], v[1]) for v in self._cr.fetchall())
|
||||
for team in self:
|
||||
team.invoiced = data_map.get(team.id, 0.0)
|
||||
|
||||
def _compute_sale_order_count(self):
|
||||
data_map = {}
|
||||
if self.ids:
|
||||
sale_order_data = self.env['sale.order']._read_group([
|
||||
('team_id', 'in', self.ids),
|
||||
('state', '!=', 'cancel'),
|
||||
], ['team_id'], ['team_id'])
|
||||
data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data}
|
||||
for team in self:
|
||||
team.sale_order_count = data_map.get(team.id, 0)
|
||||
|
||||
def _in_sale_scope(self):
|
||||
return self.env.context.get('in_sales_app')
|
||||
|
||||
def _graph_get_model(self):
|
||||
if self._in_sale_scope():
|
||||
return 'sale.report'
|
||||
return super()._graph_get_model()
|
||||
|
||||
def _graph_date_column(self):
|
||||
if self._in_sale_scope():
|
||||
return 'date'
|
||||
return super()._graph_date_column()
|
||||
|
||||
def _graph_get_table(self, GraphModel):
|
||||
if self._in_sale_scope():
|
||||
# For a team not shared between company, we make sure the amounts are expressed
|
||||
# in the currency of the team company and not converted to the current company currency,
|
||||
# as the amounts of the sale report are converted in the currency
|
||||
# of the current company (for multi-company reporting, see #83550)
|
||||
GraphModel = GraphModel.with_company(self.company_id)
|
||||
return f"({GraphModel._table_query}) AS {GraphModel._table}"
|
||||
return super()._graph_get_table(GraphModel)
|
||||
|
||||
def _graph_y_query(self):
|
||||
if self._in_sale_scope():
|
||||
return 'SUM(price_subtotal)'
|
||||
return super()._graph_y_query()
|
||||
|
||||
def _extra_sql_conditions(self):
|
||||
if self._in_sale_scope():
|
||||
return "AND state in ('sale', 'done', 'pos_done')"
|
||||
return super()._extra_sql_conditions()
|
||||
|
||||
def _graph_title_and_key(self):
|
||||
if self._in_sale_scope():
|
||||
return ['', _('Sales: Untaxed Total')] # no more title
|
||||
return super()._graph_title_and_key()
|
||||
|
||||
def _compute_dashboard_button_name(self):
|
||||
super(CrmTeam,self)._compute_dashboard_button_name()
|
||||
if self._in_sale_scope():
|
||||
self.dashboard_button_name = _("Sales Analysis")
|
||||
|
||||
def action_primary_channel_button(self):
|
||||
if self._in_sale_scope():
|
||||
return self.env["ir.actions.actions"]._for_xml_id("sale.action_order_report_so_salesteam")
|
||||
return super().action_primary_channel_button()
|
||||
|
||||
def update_invoiced_target(self, value):
|
||||
return self.write({'invoiced_target': round(float(value or 0))})
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_used_for_sales(self):
|
||||
""" If more than 5 active SOs, we consider this team to be actively used.
|
||||
5 is some random guess based on "user testing", aka more than testing
|
||||
CRM feature and less than use it in real life use cases. """
|
||||
SO_COUNT_TRIGGER = 5
|
||||
for team in self:
|
||||
if team.sale_order_count >= SO_COUNT_TRIGGER:
|
||||
raise UserError(
|
||||
_('Team %(team_name)s has %(sale_order_count)s active sale orders. Consider canceling them or archiving the team instead.',
|
||||
team_name=team.name,
|
||||
sale_order_count=team.sale_order_count
|
||||
))
|
||||
15
odoo-bringout-oca-ocb-sale/sale/models/payment_provider.py
Normal file
15
odoo-bringout-oca-ocb-sale/sale/models/payment_provider.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class PaymentProvider(models.Model):
|
||||
_inherit = 'payment.provider'
|
||||
|
||||
so_reference_type = fields.Selection(string='Communication',
|
||||
selection=[
|
||||
('so_name', 'Based on Document Reference'),
|
||||
('partner', 'Based on Customer ID')], default='so_name',
|
||||
help='You can set here the communication type that will appear on sales orders.'
|
||||
'The communication will be given to the customer when they choose the payment method.')
|
||||
237
odoo-bringout-oca-ocb-sale/sale/models/payment_transaction.py
Normal file
237
odoo-bringout-oca-ocb-sale/sale/models/payment_transaction.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
from dateutil import relativedelta
|
||||
|
||||
from odoo import _, api, Command, fields, models, SUPERUSER_ID
|
||||
from odoo.tools import format_amount, str2bool
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
sale_order_ids = fields.Many2many('sale.order', 'sale_order_transaction_rel', 'transaction_id', 'sale_order_id',
|
||||
string='Sales Orders', copy=False, readonly=True)
|
||||
sale_order_ids_nbr = fields.Integer(compute='_compute_sale_order_ids_nbr', string='# of Sales Orders')
|
||||
|
||||
def _compute_sale_order_reference(self, order):
|
||||
self.ensure_one()
|
||||
if self.provider_id.so_reference_type == 'so_name':
|
||||
order_reference = order.name
|
||||
else:
|
||||
# self.provider_id.so_reference_type == 'partner'
|
||||
identification_number = order.partner_id.id
|
||||
order_reference = '%s/%s' % ('CUST', str(identification_number % 97).rjust(2, '0'))
|
||||
|
||||
invoice_journal = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', self.env.company.id)], limit=1)
|
||||
if invoice_journal:
|
||||
order_reference = invoice_journal._process_reference_for_sale_order(order_reference)
|
||||
|
||||
return order_reference
|
||||
|
||||
@api.depends('sale_order_ids')
|
||||
def _compute_sale_order_ids_nbr(self):
|
||||
for trans in self:
|
||||
trans.sale_order_ids_nbr = len(trans.sale_order_ids)
|
||||
|
||||
def _set_pending(self, state_message=None):
|
||||
""" Override of `payment` to send the quotations automatically.
|
||||
|
||||
:param str state_message: The reason for which the transaction is set in 'pending' state.
|
||||
:return: updated transactions.
|
||||
:rtype: `payment.transaction` recordset.
|
||||
"""
|
||||
txs_to_process = super()._set_pending(state_message=state_message)
|
||||
|
||||
for tx in txs_to_process: # Consider only transactions that are indeed set pending.
|
||||
sales_orders = tx.sale_order_ids.filtered(lambda so: so.state in ['draft', 'sent'])
|
||||
sales_orders.filtered(
|
||||
lambda so: so.state == 'draft'
|
||||
).with_context(tracking_disable=True).action_quotation_sent()
|
||||
|
||||
if tx.provider_id.code == 'custom':
|
||||
for so in tx.sale_order_ids:
|
||||
so.reference = tx._compute_sale_order_reference(so)
|
||||
# send order confirmation mail.
|
||||
sales_orders._send_order_confirmation_mail()
|
||||
|
||||
return txs_to_process
|
||||
|
||||
def _check_amount_and_confirm_order(self):
|
||||
""" Confirm the sales order based on the amount of a transaction.
|
||||
|
||||
Confirm the sales orders only if the transaction amount is equal to the total amount of the
|
||||
sales orders. Neither partial payments nor grouped payments (paying multiple sales orders in
|
||||
one transaction) are not supported.
|
||||
|
||||
:return: The confirmed sales orders.
|
||||
:rtype: a `sale.order` recordset
|
||||
"""
|
||||
confirmed_orders = self.env['sale.order']
|
||||
for tx in self:
|
||||
# We only support the flow where exactly one quotation is linked to a transaction and
|
||||
# vice versa.
|
||||
if len(tx.sale_order_ids) == 1:
|
||||
quotation = tx.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent'))
|
||||
if quotation and len(quotation.transaction_ids.filtered(
|
||||
lambda tx: tx.state in ('authorized', 'done') # Only consider confirmed tx
|
||||
)) == 1:
|
||||
# Check if the SO is fully paid
|
||||
if quotation.currency_id.compare_amounts(tx.amount, quotation.amount_total) == 0:
|
||||
quotation.with_context(send_email=True).action_confirm()
|
||||
confirmed_orders |= quotation
|
||||
else:
|
||||
_logger.warning(
|
||||
'<%(provider)s> transaction AMOUNT MISMATCH for order %(so_name)s '
|
||||
'(ID %(so_id)s): expected %(so_amount)s, got %(tx_amount)s', {
|
||||
'provider': tx.provider_code,
|
||||
'so_name': quotation.name,
|
||||
'so_id': quotation.id,
|
||||
'so_amount': format_amount(
|
||||
quotation.env, quotation.amount_total, quotation.currency_id
|
||||
),
|
||||
'tx_amount': format_amount(tx.env, tx.amount, tx.currency_id),
|
||||
},
|
||||
)
|
||||
return confirmed_orders
|
||||
|
||||
def _set_authorized(self, state_message=None):
|
||||
""" Override of payment to confirm the quotations automatically. """
|
||||
super()._set_authorized(state_message=state_message)
|
||||
confirmed_orders = self._check_amount_and_confirm_order()
|
||||
confirmed_orders._send_order_confirmation_mail()
|
||||
|
||||
def _log_message_on_linked_documents(self, message):
|
||||
""" Override of payment to log a message on the sales orders linked to the transaction.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param str message: The message to be logged
|
||||
:return: None
|
||||
"""
|
||||
super()._log_message_on_linked_documents(message)
|
||||
self = self.with_user(SUPERUSER_ID) # Log messages as 'OdooBot'
|
||||
for order in self.sale_order_ids:
|
||||
order.message_post(body=message)
|
||||
|
||||
def _reconcile_after_done(self):
|
||||
""" Override of payment to automatically confirm quotations and generate invoices. """
|
||||
confirmed_orders = self._check_amount_and_confirm_order()
|
||||
confirmed_orders._send_order_confirmation_mail()
|
||||
|
||||
auto_invoice = str2bool(
|
||||
self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'))
|
||||
if auto_invoice:
|
||||
# Invoice the sale orders in self instead of in confirmed_orders to create the invoice
|
||||
# even if only a partial payment was made.
|
||||
self._invoice_sale_orders()
|
||||
super()._reconcile_after_done()
|
||||
if auto_invoice:
|
||||
# Must be called after the super() call to make sure the invoice are correctly posted.
|
||||
self._send_invoice()
|
||||
|
||||
def _send_invoice(self):
|
||||
template_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'sale.default_invoice_email_template'
|
||||
)
|
||||
if not template_id:
|
||||
return
|
||||
template_id = int(template_id)
|
||||
template = self.env['mail.template'].browse(template_id)
|
||||
for tx in self:
|
||||
tx = tx.with_company(tx.company_id).with_context(
|
||||
company_id=tx.company_id.id,
|
||||
)
|
||||
invoice_to_send = tx.invoice_ids.filtered(
|
||||
lambda i: not i.is_move_sent and i.state == 'posted' and i._is_ready_to_be_sent()
|
||||
)
|
||||
invoice_to_send.is_move_sent = True # Mark invoice as sent
|
||||
for invoice in invoice_to_send:
|
||||
lang = template._render_lang(invoice.ids)[invoice.id]
|
||||
model_desc = invoice.with_context(lang=lang).type_name
|
||||
invoice.with_context(model_description=model_desc).with_user(
|
||||
SUPERUSER_ID
|
||||
).message_post_with_template(
|
||||
template_id=template_id,
|
||||
email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature',
|
||||
)
|
||||
|
||||
def _cron_send_invoice(self):
|
||||
"""
|
||||
Cron to send invoice that where not ready to be send directly after posting
|
||||
"""
|
||||
if not self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'):
|
||||
return
|
||||
|
||||
# No need to retrieve old transactions
|
||||
retry_limit_date = datetime.now() - relativedelta.relativedelta(days=2)
|
||||
# Retrieve all transactions matching the criteria for post-processing
|
||||
self.search([
|
||||
('state', '=', 'done'),
|
||||
('is_post_processed', '=', True),
|
||||
('invoice_ids', 'in', self.env['account.move']._search([
|
||||
('is_move_sent', '=', False),
|
||||
('state', '=', 'posted'),
|
||||
])),
|
||||
('sale_order_ids.state', 'in', ('sale', 'done')),
|
||||
('last_state_change', '>=', retry_limit_date),
|
||||
])._send_invoice()
|
||||
|
||||
def _invoice_sale_orders(self):
|
||||
for tx in self.filtered(lambda tx: tx.sale_order_ids):
|
||||
# Create invoices
|
||||
tx = tx.with_company(tx.company_id).with_context(company_id=tx.company_id.id)
|
||||
confirmed_orders = tx.sale_order_ids.filtered(lambda so: so.state in ('sale', 'done'))
|
||||
if confirmed_orders:
|
||||
confirmed_orders._force_lines_to_invoice_policy_order()
|
||||
invoices = confirmed_orders.with_context(
|
||||
raise_if_nothing_to_invoice=False
|
||||
)._create_invoices()
|
||||
# Setup access token in advance to avoid serialization failure between
|
||||
# edi postprocessing of invoice and displaying the sale order on the portal
|
||||
for invoice in invoices:
|
||||
invoice._portal_ensure_token()
|
||||
tx.invoice_ids = [Command.set(invoices.ids)]
|
||||
|
||||
@api.model
|
||||
def _compute_reference_prefix(self, provider_code, separator, **values):
|
||||
""" Override of payment to compute the reference prefix based on Sales-specific values.
|
||||
|
||||
If the `values` parameter has an entry with 'sale_order_ids' as key and a list of (4, id, O)
|
||||
or (6, 0, ids) X2M command as value, the prefix is computed based on the sales order name(s)
|
||||
Otherwise, the computation is delegated to the super method.
|
||||
|
||||
:param str provider_code: The code of the provider handling the transaction
|
||||
:param str separator: The custom separator used to separate data references
|
||||
:param dict values: The transaction values used to compute the reference prefix. It should
|
||||
have the structure {'sale_order_ids': [(X2M command), ...], ...}.
|
||||
:return: The computed reference prefix if order ids are found, the one of `super` otherwise
|
||||
:rtype: str
|
||||
"""
|
||||
command_list = values.get('sale_order_ids')
|
||||
if command_list:
|
||||
# Extract sales order id(s) from the X2M commands
|
||||
order_ids = self._fields['sale_order_ids'].convert_to_cache(command_list, self)
|
||||
orders = self.env['sale.order'].browse(order_ids).exists()
|
||||
if len(orders) == len(order_ids): # All ids are valid
|
||||
return separator.join(orders.mapped('name'))
|
||||
return super()._compute_reference_prefix(provider_code, separator, **values)
|
||||
|
||||
def action_view_sales_orders(self):
|
||||
action = {
|
||||
'name': _('Sales Order(s)'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'target': 'current',
|
||||
}
|
||||
sale_order_ids = self.sale_order_ids.ids
|
||||
if len(sale_order_ids) == 1:
|
||||
action['res_id'] = sale_order_ids[0]
|
||||
action['view_mode'] = 'form'
|
||||
else:
|
||||
action['view_mode'] = 'tree,form'
|
||||
action['domain'] = [('id', 'in', sale_order_ids)]
|
||||
return action
|
||||
86
odoo-bringout-oca-ocb-sale/sale/models/product_product.py
Normal file
86
odoo-bringout-oca-ocb-sale/sale/models/product_product.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import timedelta, time
|
||||
from odoo import fields, models, _, api
|
||||
from odoo.tools.float_utils import float_round
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
|
||||
|
||||
def _compute_sales_count(self):
|
||||
r = {}
|
||||
self.sales_count = 0
|
||||
if not self.user_has_groups('sales_team.group_sale_salesman'):
|
||||
return r
|
||||
date_from = fields.Datetime.to_string(fields.datetime.combine(fields.datetime.now() - timedelta(days=365),
|
||||
time.min))
|
||||
|
||||
done_states = self.env['sale.report']._get_done_states()
|
||||
|
||||
domain = [
|
||||
('state', 'in', done_states),
|
||||
('product_id', 'in', self.ids),
|
||||
('date', '>=', date_from),
|
||||
]
|
||||
for group in self.env['sale.report']._read_group(domain, ['product_id', 'product_uom_qty'], ['product_id']):
|
||||
r[group['product_id'][0]] = group['product_uom_qty']
|
||||
for product in self:
|
||||
if not product.id:
|
||||
product.sales_count = 0.0
|
||||
continue
|
||||
product.sales_count = float_round(r.get(product.id, 0), precision_rounding=product.uom_id.rounding)
|
||||
return r
|
||||
|
||||
@api.onchange('type')
|
||||
def _onchange_type(self):
|
||||
if self._origin and self.sales_count > 0:
|
||||
return {'warning': {
|
||||
'title': _("Warning"),
|
||||
'message': _("You cannot change the product's type because it is already used in sales orders.")
|
||||
}}
|
||||
|
||||
def action_view_sales(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
|
||||
action['domain'] = [('product_id', 'in', self.ids)]
|
||||
action['context'] = {
|
||||
'pivot_measures': ['product_uom_qty'],
|
||||
'active_id': self._context.get('active_id'),
|
||||
'search_default_Sales': 1,
|
||||
'active_model': 'sale.report',
|
||||
'search_default_filter_order_date': 1,
|
||||
}
|
||||
return action
|
||||
|
||||
def _get_invoice_policy(self):
|
||||
return self.invoice_policy
|
||||
|
||||
def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False):
|
||||
"""Return the variant info based on its combination.
|
||||
See `_get_combination_info` for more information.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
|
||||
|
||||
def _filter_to_unlink(self):
|
||||
domain = [('product_id', 'in', self.ids)]
|
||||
lines = self.env['sale.order.line']._read_group(domain, ['product_id'], ['product_id'])
|
||||
linked_product_ids = [group['product_id'][0] for group in lines]
|
||||
return super(ProductProduct, self - self.browse(linked_product_ids))._filter_to_unlink()
|
||||
|
||||
|
||||
class ProductAttributeCustomValue(models.Model):
|
||||
_inherit = "product.attribute.custom.value"
|
||||
|
||||
sale_order_line_id = fields.Many2one('sale.order.line', string="Sales Order Line", required=True, ondelete='cascade')
|
||||
|
||||
_sql_constraints = [
|
||||
('sol_custom_value_unique', 'unique(custom_product_template_attribute_value_id, sale_order_line_id)', "Only one Custom Value is allowed per Attribute Value per Sales Order Line.")
|
||||
]
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = 'product.packaging'
|
||||
|
||||
sales = fields.Boolean("Sales", default=True, help="If true, the packaging can be used for sales orders")
|
||||
329
odoo-bringout-oca-ocb-sale/sale/models/product_template.py
Normal file
329
odoo-bringout-oca-ocb-sale/sale/models/product_template.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.float_utils import float_round
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
service_type = fields.Selection(
|
||||
[('manual', 'Manually set quantities on order')], string='Track Service',
|
||||
compute='_compute_service_type', store=True, readonly=False, precompute=True,
|
||||
help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
|
||||
"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
|
||||
"Create a task and track hours: Create a task on the sales order validation and track the work hours.")
|
||||
sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
|
||||
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
|
||||
expense_policy = fields.Selection(
|
||||
[('no', 'No'),
|
||||
('cost', 'At cost'),
|
||||
('sales_price', 'Sales price')
|
||||
], string='Re-Invoice Expenses', default='no',
|
||||
compute='_compute_expense_policy', store=True, readonly=False,
|
||||
help="Expenses and vendor bills can be re-invoiced to a customer."
|
||||
"With this option, a validated expense can be re-invoice to a customer at its cost or sales price.")
|
||||
visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy')
|
||||
sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure')
|
||||
visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator')
|
||||
invoice_policy = fields.Selection(
|
||||
[('order', 'Ordered quantities'),
|
||||
('delivery', 'Delivered quantities')], string='Invoicing Policy',
|
||||
compute='_compute_invoice_policy', store=True, readonly=False, precompute=True,
|
||||
help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
|
||||
'Delivered Quantity: Invoice quantities delivered to the customer.')
|
||||
|
||||
def _compute_visible_qty_configurator(self):
|
||||
for product_template in self:
|
||||
product_template.visible_qty_configurator = True
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_visible_expense_policy(self):
|
||||
visibility = self.user_has_groups('analytic.group_analytic_accounting')
|
||||
for product_template in self:
|
||||
product_template.visible_expense_policy = visibility
|
||||
|
||||
@api.depends('sale_ok')
|
||||
def _compute_expense_policy(self):
|
||||
self.filtered(lambda t: not t.sale_ok).expense_policy = 'no'
|
||||
|
||||
@api.depends('product_variant_ids.sales_count')
|
||||
def _compute_sales_count(self):
|
||||
for product in self:
|
||||
product.sales_count = float_round(sum([p.sales_count for p in product.with_context(active_test=False).product_variant_ids]), precision_rounding=product.uom_id.rounding)
|
||||
|
||||
@api.constrains('company_id')
|
||||
def _check_sale_product_company(self):
|
||||
"""Ensure the product is not being restricted to a single company while
|
||||
having been sold in another one in the past, as this could cause issues."""
|
||||
products_by_company = defaultdict(lambda: self.env['product.template'])
|
||||
for product in self:
|
||||
if not product.product_variant_ids or not product.company_id:
|
||||
# No need to check if the product has just being created (`product_variant_ids` is
|
||||
# still empty) or if we're writing `False` on its company (should always work.)
|
||||
continue
|
||||
products_by_company[product.company_id] |= product
|
||||
|
||||
for target_company, products in products_by_company.items():
|
||||
subquery_products = self.env['product.product'].sudo().with_context(active_test=False)._search([('product_tmpl_id', 'in', products.ids)])
|
||||
so_lines = self.env['sale.order.line'].sudo().search_read(
|
||||
[('product_id', 'in', subquery_products), ('company_id', '!=', target_company.id)],
|
||||
fields=['id', 'product_id']
|
||||
)
|
||||
if so_lines:
|
||||
used_products = [sol['product_id'][1] for sol in so_lines]
|
||||
raise ValidationError(_('The following products cannot be restricted to the company'
|
||||
' %(company)s because they have already been used in quotations or '
|
||||
'sales orders in another company:\n%(used_products)s\n'
|
||||
'You can archive these products and recreate them '
|
||||
'with your company restriction instead, or leave them as '
|
||||
'shared product.', company=target_company.name, used_products=', '.join(used_products)))
|
||||
|
||||
def action_view_sales(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
|
||||
action['domain'] = [('product_tmpl_id', 'in', self.ids)]
|
||||
action['context'] = {
|
||||
'pivot_measures': ['product_uom_qty'],
|
||||
'active_id': self._context.get('active_id'),
|
||||
'active_model': 'sale.report',
|
||||
'search_default_Sales': 1,
|
||||
'search_default_filter_order_date': 1,
|
||||
}
|
||||
return action
|
||||
|
||||
def create_product_variant(self, product_template_attribute_value_ids):
|
||||
""" Create if necessary and possible and return the id of the product
|
||||
variant matching the given combination for this template.
|
||||
|
||||
Note AWA: Known "exploit" issues with this method:
|
||||
|
||||
- This method could be used by an unauthenticated user to generate a
|
||||
lot of useless variants. Unfortunately, after discussing the
|
||||
matter with ODO, there's no easy and user-friendly way to block
|
||||
that behavior.
|
||||
|
||||
We would have to use captcha/server actions to clean/... that
|
||||
are all not user-friendly/overkill mechanisms.
|
||||
|
||||
- This method could be used to try to guess what product variant ids
|
||||
are created in the system and what product template ids are
|
||||
configured as "dynamic", but that does not seem like a big deal.
|
||||
|
||||
The error messages are identical on purpose to avoid giving too much
|
||||
information to a potential attacker:
|
||||
- returning 0 when failing
|
||||
- returning the variant id whether it already existed or not
|
||||
|
||||
:param product_template_attribute_value_ids: the combination for which
|
||||
to get or create variant
|
||||
:type product_template_attribute_value_ids: list of id
|
||||
of `product.template.attribute.value`
|
||||
|
||||
:return: id of the product variant matching the combination or 0
|
||||
:rtype: int
|
||||
"""
|
||||
combination = self.env['product.template.attribute.value'] \
|
||||
.browse(product_template_attribute_value_ids)
|
||||
|
||||
return self._create_product_variant(combination, log_warning=True).id or 0
|
||||
|
||||
@api.onchange('type')
|
||||
def _onchange_type(self):
|
||||
res = super(ProductTemplate, self)._onchange_type()
|
||||
if self._origin and self.sales_count > 0:
|
||||
res['warning'] = {
|
||||
'title': _("Warning"),
|
||||
'message': _("You cannot change the product's type because it is already used in sales orders.")
|
||||
}
|
||||
return res
|
||||
|
||||
@api.depends('type')
|
||||
def _compute_service_type(self):
|
||||
self.filtered(lambda t: t.type == 'consu' or not t.service_type).service_type = 'manual'
|
||||
|
||||
@api.depends('type')
|
||||
def _compute_invoice_policy(self):
|
||||
self.filtered(lambda t: t.type == 'consu' or not t.invoice_policy).invoice_policy = 'order'
|
||||
|
||||
@api.model
|
||||
def get_import_templates(self):
|
||||
res = super(ProductTemplate, self).get_import_templates()
|
||||
if self.env.context.get('sale_multi_pricelist_product_template'):
|
||||
if self.user_has_groups('product.group_sale_pricelist'):
|
||||
return [{
|
||||
'label': _('Import Template for Products'),
|
||||
'template': '/product/static/xls/product_template.xls'
|
||||
}]
|
||||
return res
|
||||
|
||||
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
|
||||
""" Return info about a given combination.
|
||||
|
||||
Note: this method does not take into account whether the combination is
|
||||
actually possible.
|
||||
|
||||
:param combination: recordset of `product.template.attribute.value`
|
||||
|
||||
:param product_id: id of a `product.product`. If no `combination`
|
||||
is set, the method will try to load the variant `product_id` if
|
||||
it exists instead of finding a variant based on the combination.
|
||||
|
||||
If there is no combination, that means we definitely want a
|
||||
variant and not something that will have no_variant set.
|
||||
|
||||
:param add_qty: float with the quantity for which to get the info,
|
||||
indeed some pricelist rules might depend on it.
|
||||
|
||||
:param pricelist: `product.pricelist` the pricelist to use
|
||||
(can be none, eg. from SO if no partner and no pricelist selected)
|
||||
|
||||
:param parent_combination: if no combination and no product_id are
|
||||
given, it will try to find the first possible combination, taking
|
||||
into account parent_combination (if set) for the exclusion rules.
|
||||
|
||||
:param only_template: boolean, if set to True, get the info for the
|
||||
template only: ignore combination and don't try to find variant
|
||||
|
||||
:return: dict with product/combination info:
|
||||
|
||||
- product_id: the variant id matching the combination (if it exists)
|
||||
|
||||
- product_template_id: the current template id
|
||||
|
||||
- display_name: the name of the combination
|
||||
|
||||
- price: the computed price of the combination, take the catalog
|
||||
price if no pricelist is given
|
||||
|
||||
- list_price: the catalog price of the combination, but this is
|
||||
not the "real" list_price, it has price_extra included (so
|
||||
it's actually more closely related to `lst_price`), and it
|
||||
is converted to the pricelist currency (if given)
|
||||
|
||||
- has_discounted_price: True if the pricelist discount policy says
|
||||
the price does not include the discount and there is actually a
|
||||
discount applied (price < list_price), else False
|
||||
"""
|
||||
self.ensure_one()
|
||||
# get the name before the change of context to benefit from prefetch
|
||||
display_name = self.display_name
|
||||
|
||||
display_image = True
|
||||
quantity = self.env.context.get('quantity', add_qty)
|
||||
product_template = self
|
||||
|
||||
combination = combination or product_template.env['product.template.attribute.value']
|
||||
|
||||
if not product_id and not combination and not only_template:
|
||||
combination = product_template._get_first_possible_combination(parent_combination)
|
||||
|
||||
if only_template:
|
||||
product = product_template.env['product.product']
|
||||
elif product_id and not combination:
|
||||
product = product_template.env['product.product'].browse(product_id)
|
||||
else:
|
||||
product = product_template._get_variant_for_combination(combination)
|
||||
|
||||
if product:
|
||||
# We need to add the price_extra for the attributes that are not
|
||||
# in the variant, typically those of type no_variant, but it is
|
||||
# possible that a no_variant attribute is still in a variant if
|
||||
# the type of the attribute has been changed after creation.
|
||||
no_variant_attributes_price_extra = [
|
||||
ptav.price_extra for ptav in combination.filtered(
|
||||
lambda ptav:
|
||||
ptav.price_extra and
|
||||
ptav not in product.product_template_attribute_value_ids
|
||||
)
|
||||
]
|
||||
if no_variant_attributes_price_extra:
|
||||
product = product.with_context(
|
||||
no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra)
|
||||
)
|
||||
list_price = product.price_compute('list_price')[product.id]
|
||||
if pricelist:
|
||||
price = pricelist._get_product_price(product, quantity)
|
||||
else:
|
||||
price = list_price
|
||||
display_image = bool(product.image_128)
|
||||
display_name = product.display_name
|
||||
price_extra = (product.price_extra or 0.0) + (sum(no_variant_attributes_price_extra) or 0.0)
|
||||
else:
|
||||
current_attributes_price_extra = [v.price_extra or 0.0 for v in combination]
|
||||
product_template = product_template.with_context(current_attributes_price_extra=current_attributes_price_extra)
|
||||
price_extra = sum(current_attributes_price_extra)
|
||||
list_price = product_template.price_compute('list_price')[product_template.id]
|
||||
if pricelist:
|
||||
price = pricelist._get_product_price(product_template, quantity)
|
||||
else:
|
||||
price = list_price
|
||||
display_image = bool(product_template.image_128)
|
||||
|
||||
combination_name = combination._get_combination_name()
|
||||
if combination_name:
|
||||
display_name = "%s (%s)" % (display_name, combination_name)
|
||||
|
||||
if pricelist and pricelist.currency_id != product_template.currency_id:
|
||||
list_price = product_template.currency_id._convert(
|
||||
list_price, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
|
||||
fields.Date.today()
|
||||
)
|
||||
price_extra = product_template.currency_id._convert(
|
||||
price_extra, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
|
||||
fields.Date.today()
|
||||
)
|
||||
|
||||
price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
|
||||
has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
|
||||
|
||||
return {
|
||||
'product_id': product.id,
|
||||
'product_template_id': product_template.id,
|
||||
'display_name': display_name,
|
||||
'display_image': display_image,
|
||||
'price': price,
|
||||
'list_price': list_price,
|
||||
'price_extra': price_extra,
|
||||
'has_discounted_price': has_discounted_price,
|
||||
}
|
||||
|
||||
def _can_be_added_to_cart(self):
|
||||
"""
|
||||
Pre-check to `_is_add_to_cart_possible` to know if product can be sold.
|
||||
"""
|
||||
return self.sale_ok
|
||||
|
||||
def _is_add_to_cart_possible(self, parent_combination=None):
|
||||
"""
|
||||
It's possible to add to cart (potentially after configuration) if
|
||||
there is at least one possible combination.
|
||||
|
||||
:param parent_combination: the combination from which `self` is an
|
||||
optional or accessory product.
|
||||
:type parent_combination: recordset `product.template.attribute.value`
|
||||
|
||||
:return: True if it's possible to add to cart, else False
|
||||
:rtype: bool
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.active or not self._can_be_added_to_cart():
|
||||
# for performance: avoid calling `_get_possible_combinations`
|
||||
return False
|
||||
return next(self._get_possible_combinations(parent_combination), False) is not False
|
||||
|
||||
def _get_current_company_fallback(self, **kwargs):
|
||||
"""Override: if a pricelist is given, fallback to the company of the
|
||||
pricelist if it is set, otherwise use the one from parent method."""
|
||||
res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
|
||||
pricelist = kwargs.get('pricelist')
|
||||
return pricelist and pricelist.company_id or res
|
||||
135
odoo-bringout-oca-ocb-sale/sale/models/res_company.py
Normal file
135
odoo-bringout-oca-ocb-sale/sale/models/res_company.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.modules.module import get_module_resource
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = "res.company"
|
||||
|
||||
portal_confirmation_sign = fields.Boolean(string='Online Signature', default=True)
|
||||
portal_confirmation_pay = fields.Boolean(string='Online Payment')
|
||||
quotation_validity_days = fields.Integer(default=30, string="Default Quotation Validity (Days)")
|
||||
|
||||
# sale quotation onboarding
|
||||
sale_quotation_onboarding_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done"), ('closed', "Closed")], string="State of the sale onboarding panel", default='not_done')
|
||||
sale_onboarding_order_confirmation_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding confirmation order step", default='not_done')
|
||||
sale_onboarding_sample_quotation_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding sample quotation step", default='not_done')
|
||||
|
||||
sale_onboarding_payment_method = fields.Selection([
|
||||
('digital_signature', 'Sign online'),
|
||||
('paypal', 'PayPal'),
|
||||
('stripe', 'Stripe'),
|
||||
('other', 'Pay with another payment provider'),
|
||||
('manual', 'Manual Payment'),
|
||||
], string="Sale onboarding selected payment method")
|
||||
|
||||
@api.model
|
||||
def action_close_sale_quotation_onboarding(self):
|
||||
""" Mark the onboarding panel as closed. """
|
||||
self.env.company.sale_quotation_onboarding_state = 'closed'
|
||||
|
||||
@api.model
|
||||
def action_open_sale_onboarding_payment_provider(self):
|
||||
""" Called by onboarding panel above the quotation list."""
|
||||
self.env.company.get_chart_of_accounts_or_fail()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_open_sale_payment_provider_onboarding_wizard")
|
||||
return action
|
||||
|
||||
def _mark_payment_onboarding_step_as_done(self):
|
||||
""" Override of payment to mark the sale onboarding step as done.
|
||||
|
||||
The payment onboarding step of Sales is only marked as done if it was started from Sales.
|
||||
This prevents incorrectly marking the step as done if another module's payment onboarding
|
||||
step was marked as done.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
super()._mark_payment_onboarding_step_as_done()
|
||||
if self.sale_onboarding_payment_method: # The onboarding step was started from Sales
|
||||
self.set_onboarding_step_done('sale_onboarding_order_confirmation_state')
|
||||
|
||||
def _get_sample_sales_order(self):
|
||||
""" Get a sample quotation or create one if it does not exist. """
|
||||
# use current user as partner
|
||||
partner = self.env.user.partner_id
|
||||
company_id = self.env.company.id
|
||||
# is there already one?
|
||||
sample_sales_order = self.env['sale.order'].search(
|
||||
[('company_id', '=', company_id), ('partner_id', '=', partner.id),
|
||||
('state', '=', 'draft')], limit=1)
|
||||
if len(sample_sales_order) == 0:
|
||||
sample_sales_order = self.env['sale.order'].create({
|
||||
'partner_id': partner.id
|
||||
})
|
||||
# take any existing product or create one
|
||||
product = self.env['product.product'].search([], limit=1)
|
||||
if len(product) == 0:
|
||||
default_image_path = get_module_resource('product', 'static/img', 'product_product_13-image.jpg')
|
||||
product = self.env['product.product'].create({
|
||||
'name': _('Sample Product'),
|
||||
'active': False,
|
||||
'image_1920': base64.b64encode(open(default_image_path, 'rb').read())
|
||||
})
|
||||
product.product_tmpl_id.write({'active': False})
|
||||
self.env['sale.order.line'].create({
|
||||
'name': _('Sample Order Line'),
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 10,
|
||||
'price_unit': 123,
|
||||
'order_id': sample_sales_order.id,
|
||||
'company_id': sample_sales_order.company_id.id,
|
||||
})
|
||||
return sample_sales_order
|
||||
|
||||
@api.model
|
||||
def action_open_sale_onboarding_sample_quotation(self):
|
||||
""" Onboarding step for sending a sample quotation. Open a window to compose an email,
|
||||
with the edi_invoice_template message loaded by default. """
|
||||
sample_sales_order = self._get_sample_sales_order()
|
||||
template = self.env.ref('sale.email_template_edi_sale', False)
|
||||
|
||||
message_composer = self.env['mail.compose.message'].with_context(
|
||||
default_use_template=bool(template),
|
||||
mark_so_as_sent=True,
|
||||
default_email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature',
|
||||
proforma=self.env.context.get('proforma', False),
|
||||
force_email=True, mail_notify_author=True
|
||||
).create({
|
||||
'res_id': sample_sales_order.id,
|
||||
'template_id': template and template.id or False,
|
||||
'model': 'sale.order',
|
||||
'composition_mode': 'comment'})
|
||||
|
||||
# Simulate the onchange (like trigger in form the view)
|
||||
update_values = message_composer._onchange_template_id(template.id, 'comment', 'sale.order', sample_sales_order.id)['value']
|
||||
message_composer.write(update_values)
|
||||
|
||||
message_composer._action_send_mail()
|
||||
|
||||
self.set_onboarding_step_done('sale_onboarding_sample_quotation_state')
|
||||
|
||||
self.action_close_sale_quotation_onboarding()
|
||||
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders")
|
||||
action.update({
|
||||
'views': [[self.env.ref('sale.view_order_form').id, 'form']],
|
||||
'view_mode': 'form',
|
||||
'target': 'main',
|
||||
})
|
||||
return action
|
||||
|
||||
def get_and_update_sale_quotation_onboarding_state(self):
|
||||
""" This method is called on the controller rendering method and ensures that the animations
|
||||
are displayed only one time. """
|
||||
steps = [
|
||||
'base_onboarding_company_state',
|
||||
'account_onboarding_invoice_layout_state',
|
||||
'sale_onboarding_order_confirmation_state',
|
||||
'sale_onboarding_sample_quotation_state',
|
||||
]
|
||||
return self._get_and_update_onboarding_state('sale_quotation_onboarding_state', steps)
|
||||
|
||||
_sql_constraints = [('check_quotation_validity_days', 'CHECK(quotation_validity_days > 0)', 'Quotation Validity is required and must be greater than 0.')]
|
||||
101
odoo-bringout-oca-ocb-sale/sale/models/res_config_settings.py
Normal file
101
odoo-bringout-oca-ocb-sale/sale/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# Defaults
|
||||
default_invoice_policy = fields.Selection([
|
||||
('order', 'Invoice what is ordered'),
|
||||
('delivery', 'Invoice what is delivered')
|
||||
], 'Invoicing Policy',
|
||||
default='order',
|
||||
default_model='product.template')
|
||||
|
||||
# Groups
|
||||
group_auto_done_setting = fields.Boolean(
|
||||
string="Lock Confirmed Sales", implied_group='sale.group_auto_done_setting')
|
||||
group_proforma_sales = fields.Boolean(
|
||||
string="Pro-Forma Invoice", implied_group='sale.group_proforma_sales',
|
||||
help="Allows you to send pro-forma invoice.")
|
||||
group_warning_sale = fields.Boolean("Sale Order Warnings", implied_group='sale.group_warning_sale')
|
||||
|
||||
# Config params
|
||||
automatic_invoice = fields.Boolean(
|
||||
string="Automatic Invoice",
|
||||
help="The invoice is generated automatically and available in the customer portal when the "
|
||||
"transaction is confirmed by the payment provider.\nThe invoice is marked as paid and "
|
||||
"the payment is registered in the payment journal defined in the configuration of the "
|
||||
"payment provider.\nThis mode is advised if you issue the final invoice at the order "
|
||||
"and not after the delivery.",
|
||||
config_parameter='sale.automatic_invoice',
|
||||
)
|
||||
deposit_default_product_id = fields.Many2one(
|
||||
'product.product',
|
||||
'Deposit Product',
|
||||
domain="[('type', '=', 'service')]",
|
||||
config_parameter='sale.default_deposit_product_id',
|
||||
help='Default product used for payment advances')
|
||||
|
||||
invoice_mail_template_id = fields.Many2one(
|
||||
comodel_name='mail.template',
|
||||
string='Invoice Email Template',
|
||||
domain="[('model', '=', 'account.move')]",
|
||||
config_parameter='sale.default_invoice_email_template',
|
||||
help="Email sent to the customer once the invoice is available.",
|
||||
)
|
||||
|
||||
use_quotation_validity_days = fields.Boolean(
|
||||
"Default Quotation Validity", config_parameter='sale.use_quotation_validity_days')
|
||||
|
||||
# Company setup
|
||||
quotation_validity_days = fields.Integer(
|
||||
related='company_id.quotation_validity_days', string="Default Quotation Validity (Days)", readonly=False)
|
||||
portal_confirmation_sign = fields.Boolean(
|
||||
related='company_id.portal_confirmation_sign', string='Online Signature', readonly=False)
|
||||
portal_confirmation_pay = fields.Boolean(
|
||||
related='company_id.portal_confirmation_pay', string='Online Payment', readonly=False)
|
||||
|
||||
# Modules
|
||||
module_delivery = fields.Boolean("Delivery Methods")
|
||||
module_delivery_bpost = fields.Boolean("bpost Connector")
|
||||
module_delivery_dhl = fields.Boolean("DHL Express Connector")
|
||||
module_delivery_easypost = fields.Boolean("Easypost Connector")
|
||||
module_delivery_sendcloud = fields.Boolean("Sendcloud Connector")
|
||||
module_delivery_fedex = fields.Boolean("FedEx Connector")
|
||||
module_delivery_ups = fields.Boolean("UPS Connector")
|
||||
module_delivery_usps = fields.Boolean("USPS Connector")
|
||||
|
||||
module_product_email_template = fields.Boolean("Specific Email")
|
||||
module_sale_amazon = fields.Boolean("Amazon Sync")
|
||||
module_sale_loyalty = fields.Boolean("Coupons & Loyalty")
|
||||
module_sale_margin = fields.Boolean("Margins")
|
||||
|
||||
#=== ONCHANGE METHODS ===#
|
||||
|
||||
@api.onchange('use_quotation_validity_days')
|
||||
def _onchange_use_quotation_validity_days(self):
|
||||
if self.quotation_validity_days <= 0:
|
||||
self.quotation_validity_days = self.env['res.company'].default_get(['quotation_validity_days'])['quotation_validity_days']
|
||||
|
||||
@api.onchange('quotation_validity_days')
|
||||
def _onchange_quotation_validity_days(self):
|
||||
if self.quotation_validity_days <= 0:
|
||||
self.quotation_validity_days = self.env['res.company'].default_get(['quotation_validity_days'])['quotation_validity_days']
|
||||
return {
|
||||
'warning': {'title': _("Warning"), 'message': _("Quotation Validity is required and must be greater than 0.")},
|
||||
}
|
||||
|
||||
#=== CRUD METHODS ===#
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
if self.default_invoice_policy != 'order':
|
||||
self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
|
||||
|
||||
send_invoice_cron = self.env.ref('sale.send_invoice_cron', raise_if_not_found=False)
|
||||
if send_invoice_cron and send_invoice_cron.active != self.automatic_invoice:
|
||||
send_invoice_cron.active = self.automatic_invoice
|
||||
54
odoo-bringout-oca-ocb-sale/sale/models/res_partner.py
Normal file
54
odoo-bringout-oca-ocb-sale/sale/models/res_partner.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
|
||||
from odoo.osv import expression
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
sale_order_count = fields.Integer(compute='_compute_sale_order_count', string='Sale Order Count')
|
||||
sale_order_ids = fields.One2many('sale.order', 'partner_id', 'Sales Order')
|
||||
sale_warn = fields.Selection(WARNING_MESSAGE, 'Sales Warnings', default='no-message', help=WARNING_HELP)
|
||||
sale_warn_msg = fields.Text('Message for Sales Order')
|
||||
|
||||
@api.model
|
||||
def _get_sale_order_domain_count(self):
|
||||
return []
|
||||
|
||||
def _compute_sale_order_count(self):
|
||||
# retrieve all children partners
|
||||
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
|
||||
sale_order_groups = self.env['sale.order']._read_group(
|
||||
domain=expression.AND([self._get_sale_order_domain_count(), [('partner_id', 'in', all_partners.ids)]]),
|
||||
fields=['partner_id'], groupby=['partner_id']
|
||||
)
|
||||
partners = self.browse()
|
||||
for group in sale_order_groups:
|
||||
partner = self.browse(group['partner_id'][0])
|
||||
while partner:
|
||||
if partner in self:
|
||||
partner.sale_order_count += group['partner_id_count']
|
||||
partners |= partner
|
||||
partner = partner.parent_id
|
||||
(self - partners).sale_order_count = 0
|
||||
|
||||
def can_edit_vat(self):
|
||||
''' Can't edit `vat` if there is (non draft) issued SO. '''
|
||||
can_edit_vat = super(ResPartner, self).can_edit_vat()
|
||||
if not can_edit_vat:
|
||||
return can_edit_vat
|
||||
SaleOrder = self.env['sale.order']
|
||||
has_so = SaleOrder.sudo().search([
|
||||
('partner_id', 'child_of', self.commercial_partner_id.id),
|
||||
('state', 'in', ['sent', 'sale', 'done'])
|
||||
], limit=1)
|
||||
return can_edit_vat and not bool(has_so)
|
||||
|
||||
def action_view_sale_order(self):
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('sale.act_res_partner_2_sale_order')
|
||||
all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
action["domain"] = [("partner_id", "in", all_child.ids)]
|
||||
return action
|
||||
1510
odoo-bringout-oca-ocb-sale/sale/models/sale_order.py
Normal file
1510
odoo-bringout-oca-ocb-sale/sale/models/sale_order.py
Normal file
File diff suppressed because it is too large
Load diff
1203
odoo-bringout-oca-ocb-sale/sale/models/sale_order_line.py
Normal file
1203
odoo-bringout-oca-ocb-sale/sale/models/sale_order_line.py
Normal file
File diff suppressed because it is too large
Load diff
69
odoo-bringout-oca-ocb-sale/sale/models/utm_campaign.py
Normal file
69
odoo-bringout-oca-ocb-sale/sale/models/utm_campaign.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class UtmCampaign(models.Model):
|
||||
_inherit = 'utm.campaign'
|
||||
_description = 'UTM Campaign'
|
||||
|
||||
quotation_count = fields.Integer('Quotation Count',
|
||||
compute="_compute_quotation_count", compute_sudo=True, groups='sales_team.group_sale_salesman')
|
||||
invoiced_amount = fields.Integer(string="Revenues generated by the campaign",
|
||||
compute="_compute_sale_invoiced_amount", compute_sudo=True, groups='sales_team.group_sale_salesman')
|
||||
company_id = fields.Many2one('res.company', string='Company', readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company)
|
||||
currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string='Currency')
|
||||
|
||||
def _compute_quotation_count(self):
|
||||
quotation_data = self.env['sale.order']._read_group([
|
||||
('campaign_id', 'in', self.ids)],
|
||||
['campaign_id'], ['campaign_id'])
|
||||
data_map = {datum['campaign_id'][0]: datum['campaign_id_count'] for datum in quotation_data}
|
||||
for campaign in self:
|
||||
campaign.quotation_count = data_map.get(campaign.id, 0)
|
||||
|
||||
def _compute_sale_invoiced_amount(self):
|
||||
self.env['account.move.line'].flush_model(['balance', 'move_id', 'account_id', 'display_type'])
|
||||
self.env['account.move'].flush_model(['state', 'campaign_id', 'move_type'])
|
||||
query = """SELECT move.campaign_id, -SUM(line.balance) as price_subtotal
|
||||
FROM account_move_line line
|
||||
INNER JOIN account_move move ON line.move_id = move.id
|
||||
WHERE move.state not in ('draft', 'cancel')
|
||||
AND move.campaign_id IN %s
|
||||
AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')
|
||||
AND line.account_id IS NOT NULL
|
||||
AND line.display_type = 'product'
|
||||
GROUP BY move.campaign_id
|
||||
"""
|
||||
|
||||
self._cr.execute(query, [tuple(self.ids)])
|
||||
query_res = self._cr.dictfetchall()
|
||||
|
||||
campaigns = self.browse()
|
||||
for datum in query_res:
|
||||
campaign = self.browse(datum['campaign_id'])
|
||||
campaign.invoiced_amount = datum['price_subtotal']
|
||||
campaigns |= campaign
|
||||
for campaign in (self - campaigns):
|
||||
campaign.invoiced_amount = 0
|
||||
|
||||
def action_redirect_to_quotations(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_quotations_with_onboarding")
|
||||
action['domain'] = [('campaign_id', '=', self.id)]
|
||||
action['context'] = {'default_campaign_id': self.id}
|
||||
return action
|
||||
|
||||
def action_redirect_to_invoiced(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
|
||||
invoices = self.env['account.move'].search([('campaign_id', '=', self.id)])
|
||||
action['context'] = {
|
||||
'create': False,
|
||||
'edit': False,
|
||||
'view_no_maturity': True
|
||||
}
|
||||
action['domain'] = [
|
||||
('id', 'in', invoices.ids),
|
||||
('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')),
|
||||
('state', 'not in', ['draft', 'cancel'])
|
||||
]
|
||||
return action
|
||||
Loading…
Add table
Add a link
Reference in a new issue