summaryrefslogtreecommitdiff
path: root/addons/purchase/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/purchase/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/purchase/models')
-rw-r--r--addons/purchase/models/__init__.py10
-rw-r--r--addons/purchase/models/account_invoice.py141
-rw-r--r--addons/purchase/models/mail_compose_message.py13
-rw-r--r--addons/purchase/models/product.py96
-rw-r--r--addons/purchase/models/purchase.py1235
-rw-r--r--addons/purchase/models/res_company.py28
-rw-r--r--addons/purchase/models/res_config_settings.py44
-rw-r--r--addons/purchase/models/res_partner.py66
8 files changed, 1633 insertions, 0 deletions
diff --git a/addons/purchase/models/__init__.py b/addons/purchase/models/__init__.py
new file mode 100644
index 00000000..f2f1fa55
--- /dev/null
+++ b/addons/purchase/models/__init__.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_invoice
+from . import purchase
+from . import product
+from . import res_company
+from . import res_config_settings
+from . import res_partner
+from . import mail_compose_message
diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py
new file mode 100644
index 00000000..b60380c4
--- /dev/null
+++ b/addons/purchase/models/account_invoice.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ purchase_vendor_bill_id = fields.Many2one('purchase.bill.union', store=False, readonly=True,
+ states={'draft': [('readonly', False)]},
+ string='Auto-complete',
+ help="Auto-complete from a past bill / purchase order.")
+ purchase_id = fields.Many2one('purchase.order', store=False, readonly=True,
+ states={'draft': [('readonly', False)]},
+ string='Purchase Order',
+ help="Auto-complete from a past purchase order.")
+
+ def _get_invoice_reference(self):
+ self.ensure_one()
+ vendor_refs = [ref for ref in set(self.line_ids.mapped('purchase_line_id.order_id.partner_ref')) if ref]
+ if self.ref:
+ return [ref for ref in self.ref.split(', ') if ref and ref not in vendor_refs] + vendor_refs
+ return vendor_refs
+
+ @api.onchange('purchase_vendor_bill_id', 'purchase_id')
+ def _onchange_purchase_auto_complete(self):
+ ''' Load from either an old purchase order, either an old vendor bill.
+
+ When setting a 'purchase.bill.union' in 'purchase_vendor_bill_id':
+ * If it's a vendor bill, 'invoice_vendor_bill_id' is set and the loading is done by '_onchange_invoice_vendor_bill'.
+ * If it's a purchase order, 'purchase_id' is set and this method will load lines.
+
+ /!\ All this not-stored fields must be empty at the end of this function.
+ '''
+ if self.purchase_vendor_bill_id.vendor_bill_id:
+ self.invoice_vendor_bill_id = self.purchase_vendor_bill_id.vendor_bill_id
+ self._onchange_invoice_vendor_bill()
+ elif self.purchase_vendor_bill_id.purchase_order_id:
+ self.purchase_id = self.purchase_vendor_bill_id.purchase_order_id
+ self.purchase_vendor_bill_id = False
+
+ if not self.purchase_id:
+ return
+
+ # Copy data from PO
+ invoice_vals = self.purchase_id.with_company(self.purchase_id.company_id)._prepare_invoice()
+ invoice_vals['currency_id'] = self.line_ids and self.currency_id or invoice_vals.get('currency_id')
+ del invoice_vals['ref']
+ self.update(invoice_vals)
+
+ # Copy purchase lines.
+ po_lines = self.purchase_id.order_line - self.line_ids.mapped('purchase_line_id')
+ new_lines = self.env['account.move.line']
+ for line in po_lines.filtered(lambda l: not l.display_type):
+ new_line = new_lines.new(line._prepare_account_move_line(self))
+ new_line.account_id = new_line._get_computed_account()
+ new_line._onchange_price_subtotal()
+ new_lines += new_line
+ new_lines._onchange_mark_recompute_taxes()
+
+ # Compute invoice_origin.
+ origins = set(self.line_ids.mapped('purchase_line_id.order_id.name'))
+ self.invoice_origin = ','.join(list(origins))
+
+ # Compute ref.
+ refs = self._get_invoice_reference()
+ self.ref = ', '.join(refs)
+
+ # Compute payment_reference.
+ if len(refs) == 1:
+ self.payment_reference = refs[0]
+
+ self.purchase_id = False
+ self._onchange_currency()
+ self.partner_bank_id = self.bank_partner_id.bank_ids and self.bank_partner_id.bank_ids[0]
+
+ @api.onchange('partner_id', 'company_id')
+ def _onchange_partner_id(self):
+ res = super(AccountMove, self)._onchange_partner_id()
+ if self.partner_id and\
+ self.move_type in ['in_invoice', 'in_refund'] and\
+ self.currency_id != self.partner_id.property_purchase_currency_id and\
+ self.partner_id.property_purchase_currency_id.id:
+ if not self.env.context.get('default_journal_id'):
+ journal_domain = [
+ ('type', '=', 'purchase'),
+ ('company_id', '=', self.company_id.id),
+ ('currency_id', '=', self.partner_id.property_purchase_currency_id.id),
+ ]
+ default_journal_id = self.env['account.journal'].search(journal_domain, limit=1)
+ if default_journal_id:
+ self.journal_id = default_journal_id
+ if self.env.context.get('default_currency_id'):
+ self.currency_id = self.env.context['default_currency_id']
+ if self.partner_id.property_purchase_currency_id:
+ self.currency_id = self.partner_id.property_purchase_currency_id
+ return res
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # OVERRIDE
+ moves = super(AccountMove, self).create(vals_list)
+ for move in moves:
+ if move.reversed_entry_id:
+ continue
+ purchase = move.line_ids.mapped('purchase_line_id.order_id')
+ if not purchase:
+ continue
+ refs = ["<a href=# data-oe-model=purchase.order data-oe-id=%s>%s</a>" % tuple(name_get) for name_get in purchase.name_get()]
+ message = _("This vendor bill has been created from: %s") % ','.join(refs)
+ move.message_post(body=message)
+ return moves
+
+ def write(self, vals):
+ # OVERRIDE
+ old_purchases = [move.mapped('line_ids.purchase_line_id.order_id') for move in self]
+ res = super(AccountMove, self).write(vals)
+ for i, move in enumerate(self):
+ new_purchases = move.mapped('line_ids.purchase_line_id.order_id')
+ if not new_purchases:
+ continue
+ diff_purchases = new_purchases - old_purchases[i]
+ if diff_purchases:
+ refs = ["<a href=# data-oe-model=purchase.order data-oe-id=%s>%s</a>" % tuple(name_get) for name_get in diff_purchases.name_get()]
+ message = _("This vendor bill has been modified from: %s") % ','.join(refs)
+ move.message_post(body=message)
+ return res
+
+
+class AccountMoveLine(models.Model):
+ """ Override AccountInvoice_line to add the link to the purchase order line it is related to"""
+ _inherit = 'account.move.line'
+
+ purchase_line_id = fields.Many2one('purchase.order.line', 'Purchase Order Line', ondelete='set null', index=True)
+ purchase_order_id = fields.Many2one('purchase.order', 'Purchase Order', related='purchase_line_id.order_id', readonly=True)
+
+ def _copy_data_extend_business_fields(self, values):
+ # OVERRIDE to copy the 'purchase_line_id' field as well.
+ super(AccountMoveLine, self)._copy_data_extend_business_fields(values)
+ values['purchase_line_id'] = self.purchase_line_id.id
diff --git a/addons/purchase/models/mail_compose_message.py b/addons/purchase/models/mail_compose_message.py
new file mode 100644
index 00000000..a82e5924
--- /dev/null
+++ b/addons/purchase/models/mail_compose_message.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# purches Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class MailComposeMessage(models.TransientModel):
+ _inherit = 'mail.compose.message'
+
+ def send_mail(self, auto_commit=False):
+ if self.env.context.get('mark_rfq_as_sent') and self.model == 'purchase.order':
+ self = self.with_context(mail_notify_author=self.env.user.partner_id in self.partner_ids)
+ return super(MailComposeMessage, self).send_mail(auto_commit=auto_commit)
diff --git a/addons/purchase/models/product.py b/addons/purchase/models/product.py
new file mode 100644
index 00000000..197d4af6
--- /dev/null
+++ b/addons/purchase/models/product.py
@@ -0,0 +1,96 @@
+# -*- 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.tools.float_utils import float_round
+from dateutil.relativedelta import relativedelta
+
+
+class ProductTemplate(models.Model):
+ _name = 'product.template'
+ _inherit = 'product.template'
+
+ property_account_creditor_price_difference = fields.Many2one(
+ 'account.account', string="Price Difference Account", company_dependent=True,
+ help="This account is used in automated inventory valuation to "\
+ "record the price difference between a purchase order and its related vendor bill when validating this vendor bill.")
+ purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased')
+ purchase_method = fields.Selection([
+ ('purchase', 'On ordered quantities'),
+ ('receive', 'On received quantities'),
+ ], string="Control Policy", help="On ordered quantities: Control bills based on ordered quantities.\n"
+ "On received quantities: Control bills based on received quantities.", default="receive")
+ purchase_line_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Line Warning', help=WARNING_HELP, required=True, default="no-message")
+ purchase_line_warn_msg = fields.Text('Message for Purchase Order Line')
+
+ def _compute_purchased_product_qty(self):
+ for template in self:
+ template.purchased_product_qty = float_round(sum([p.purchased_product_qty for p in template.product_variant_ids]), precision_rounding=template.uom_id.rounding)
+
+ @api.model
+ def get_import_templates(self):
+ res = super(ProductTemplate, self).get_import_templates()
+ if self.env.context.get('purchase_product_template'):
+ return [{
+ 'label': _('Import Template for Products'),
+ 'template': '/purchase/static/xls/product_purchase.xls'
+ }]
+ return res
+
+ def action_view_po(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_order_report_all")
+ action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_tmpl_id', 'in', self.ids)]
+ action['context'] = {
+ 'graph_measure': 'qty_ordered',
+ 'search_default_later_than_a_year_ago': True
+ }
+ return action
+
+
+class ProductProduct(models.Model):
+ _name = 'product.product'
+ _inherit = 'product.product'
+
+ purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased')
+
+ def _compute_purchased_product_qty(self):
+ date_from = fields.Datetime.to_string(fields.Date.context_today(self) - relativedelta(years=1))
+ domain = [
+ ('order_id.state', 'in', ['purchase', 'done']),
+ ('product_id', 'in', self.ids),
+ ('order_id.date_approve', '>=', date_from)
+ ]
+ order_lines = self.env['purchase.order.line'].read_group(domain, ['product_id', 'product_uom_qty'], ['product_id'])
+ purchased_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in order_lines])
+ for product in self:
+ if not product.id:
+ product.purchased_product_qty = 0.0
+ continue
+ product.purchased_product_qty = float_round(purchased_data.get(product.id, 0), precision_rounding=product.uom_id.rounding)
+
+ def action_view_po(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_order_report_all")
+ action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.ids)]
+ action['context'] = {
+ 'graph_measure': 'qty_ordered',
+ 'search_default_later_than_a_year_ago': True
+ }
+ return action
+
+
+class ProductCategory(models.Model):
+ _inherit = "product.category"
+
+ property_account_creditor_price_difference_categ = fields.Many2one(
+ 'account.account', string="Price Difference Account",
+ company_dependent=True,
+ help="This account will be used to value price difference between purchase price and accounting cost.")
+
+
+class ProductSupplierinfo(models.Model):
+ _inherit = "product.supplierinfo"
+
+ @api.onchange('name')
+ def _onchange_name(self):
+ self.currency_id = self.name.property_purchase_currency_id.id or self.env.company.currency_id.id
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
new file mode 100644
index 00000000..35228b45
--- /dev/null
+++ b/addons/purchase/models/purchase.py
@@ -0,0 +1,1235 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import datetime, time
+from dateutil.relativedelta import relativedelta
+from itertools import groupby
+from pytz import timezone, UTC
+from werkzeug.urls import url_encode
+
+from odoo import api, fields, models, _
+from odoo.osv import expression
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
+from odoo.tools.float_utils import float_is_zero
+from odoo.exceptions import AccessError, UserError, ValidationError
+from odoo.tools.misc import formatLang, get_lang
+
+
+class PurchaseOrder(models.Model):
+ _name = "purchase.order"
+ _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin']
+ _description = "Purchase Order"
+ _order = 'priority desc, id desc'
+
+ @api.depends('order_line.price_total')
+ def _amount_all(self):
+ for order in self:
+ amount_untaxed = amount_tax = 0.0
+ for line in order.order_line:
+ line._compute_amount()
+ amount_untaxed += line.price_subtotal
+ amount_tax += line.price_tax
+ currency = order.currency_id or order.partner_id.property_purchase_currency_id or self.env.company.currency_id
+ order.update({
+ 'amount_untaxed': currency.round(amount_untaxed),
+ 'amount_tax': currency.round(amount_tax),
+ 'amount_total': amount_untaxed + amount_tax,
+ })
+
+ @api.depends('state', 'order_line.qty_to_invoice')
+ def _get_invoiced(self):
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ for order in self:
+ if order.state not in ('purchase', 'done'):
+ order.invoice_status = 'no'
+ continue
+
+ if any(
+ not float_is_zero(line.qty_to_invoice, precision_digits=precision)
+ for line in order.order_line.filtered(lambda l: not l.display_type)
+ ):
+ order.invoice_status = 'to invoice'
+ elif (
+ all(
+ float_is_zero(line.qty_to_invoice, precision_digits=precision)
+ for line in order.order_line.filtered(lambda l: not l.display_type)
+ )
+ and order.invoice_ids
+ ):
+ order.invoice_status = 'invoiced'
+ else:
+ order.invoice_status = 'no'
+
+ @api.depends('order_line.invoice_lines.move_id')
+ def _compute_invoice(self):
+ for order in self:
+ invoices = order.mapped('order_line.invoice_lines.move_id')
+ order.invoice_ids = invoices
+ order.invoice_count = len(invoices)
+
+ READONLY_STATES = {
+ 'purchase': [('readonly', True)],
+ 'done': [('readonly', True)],
+ 'cancel': [('readonly', True)],
+ }
+
+ name = fields.Char('Order Reference', required=True, index=True, copy=False, default='New')
+ priority = fields.Selection(
+ [('0', 'Normal'), ('1', 'Urgent')], 'Priority', default='0', index=True)
+ origin = fields.Char('Source Document', copy=False,
+ help="Reference of the document that generated this purchase order "
+ "request (e.g. a sales order)")
+ partner_ref = fields.Char('Vendor Reference', copy=False,
+ help="Reference of the sales order or bid sent by the vendor. "
+ "It's used to do the matching when you receive the "
+ "products as this reference is usually written on the "
+ "delivery order sent by your vendor.")
+ date_order = fields.Datetime('Order Deadline', required=True, states=READONLY_STATES, index=True, copy=False, default=fields.Datetime.now,
+ help="Depicts the date within which the Quotation should be confirmed and converted into a purchase order.")
+ date_approve = fields.Datetime('Confirmation Date', readonly=1, index=True, copy=False)
+ partner_id = fields.Many2one('res.partner', string='Vendor', required=True, states=READONLY_STATES, change_default=True, tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="You can find a vendor by its Name, TIN, Email or Internal Reference.")
+ dest_address_id = fields.Many2one('res.partner', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", string='Drop Ship Address', states=READONLY_STATES,
+ help="Put an address if you want to deliver directly from the vendor to the customer. "
+ "Otherwise, keep empty to deliver to your own company.")
+ currency_id = fields.Many2one('res.currency', 'Currency', required=True, states=READONLY_STATES,
+ default=lambda self: self.env.company.currency_id.id)
+ state = fields.Selection([
+ ('draft', 'RFQ'),
+ ('sent', 'RFQ Sent'),
+ ('to approve', 'To Approve'),
+ ('purchase', 'Purchase Order'),
+ ('done', 'Locked'),
+ ('cancel', 'Cancelled')
+ ], string='Status', readonly=True, index=True, copy=False, default='draft', tracking=True)
+ order_line = fields.One2many('purchase.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True)
+ notes = fields.Text('Terms and Conditions')
+
+ invoice_count = fields.Integer(compute="_compute_invoice", string='Bill Count', copy=False, default=0, store=True)
+ invoice_ids = fields.Many2many('account.move', compute="_compute_invoice", string='Bills', copy=False, store=True)
+ invoice_status = fields.Selection([
+ ('no', 'Nothing to Bill'),
+ ('to invoice', 'Waiting Bills'),
+ ('invoiced', 'Fully Billed'),
+ ], string='Billing Status', compute='_get_invoiced', store=True, readonly=True, copy=False, default='no')
+ date_planned = fields.Datetime(
+ string='Receipt Date', index=True, copy=False, compute='_compute_date_planned', store=True, readonly=False,
+ help="Delivery date promised by vendor. This date is used to determine expected arrival of products.")
+ date_calendar_start = fields.Datetime(compute='_compute_date_calendar_start', readonly=True, store=True)
+
+ amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=True)
+ amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all')
+ amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_amount_all')
+
+ fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ payment_term_id = fields.Many2one('account.payment.term', 'Payment Terms', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ incoterm_id = fields.Many2one('account.incoterms', 'Incoterm', states={'done': [('readonly', True)]}, help="International Commercial Terms are a series of predefined commercial terms used in international transactions.")
+
+ product_id = fields.Many2one('product.product', related='order_line.product_id', string='Product', readonly=False)
+ user_id = fields.Many2one(
+ 'res.users', string='Purchase Representative', index=True, tracking=True,
+ default=lambda self: self.env.user, check_company=True)
+ company_id = fields.Many2one('res.company', 'Company', required=True, index=True, states=READONLY_STATES, default=lambda self: self.env.company.id)
+ currency_rate = fields.Float("Currency Rate", compute='_compute_currency_rate', compute_sudo=True, store=True, readonly=True, help='Ratio between the purchase order currency and the company currency')
+
+ mail_reminder_confirmed = fields.Boolean("Reminder Confirmed", default=False, readonly=True, copy=False, help="True if the reminder email is confirmed by the vendor.")
+ mail_reception_confirmed = fields.Boolean("Reception Confirmed", default=False, readonly=True, copy=False, help="True if PO reception is confirmed by the vendor.")
+
+ receipt_reminder_email = fields.Boolean('Receipt Reminder Email', related='partner_id.receipt_reminder_email', readonly=False)
+ reminder_date_before_receipt = fields.Integer('Days Before Receipt', related='partner_id.reminder_date_before_receipt', readonly=False)
+
+ @api.constrains('company_id', 'order_line')
+ def _check_order_line_company_id(self):
+ for order in self:
+ companies = order.order_line.product_id.company_id
+ if companies and companies != order.company_id:
+ bad_products = order.order_line.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id)
+ raise ValidationError(_(
+ "Your quotation contains products from company %(product_company)s whereas your quotation belongs to company %(quote_company)s. \n Please change the company of your quotation or remove the products from other companies (%(bad_products)s).",
+ product_company=', '.join(companies.mapped('display_name')),
+ quote_company=order.company_id.display_name,
+ bad_products=', '.join(bad_products.mapped('display_name')),
+ ))
+
+ def _compute_access_url(self):
+ super(PurchaseOrder, self)._compute_access_url()
+ for order in self:
+ order.access_url = '/my/purchase/%s' % (order.id)
+
+ @api.depends('state', 'date_order', 'date_approve')
+ def _compute_date_calendar_start(self):
+ for order in self:
+ order.date_calendar_start = order.date_approve if (order.state in ['purchase', 'done']) else order.date_order
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ args = args or []
+ domain = []
+ if name:
+ domain = ['|', ('name', operator, name), ('partner_ref', operator, name)]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ @api.depends('date_order', 'currency_id', 'company_id', 'company_id.currency_id')
+ def _compute_currency_rate(self):
+ for order in self:
+ order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order)
+
+ @api.depends('order_line.date_planned')
+ def _compute_date_planned(self):
+ """ date_planned = the earliest date_planned across all order lines. """
+ for order in self:
+ dates_list = order.order_line.filtered(lambda x: not x.display_type and x.date_planned).mapped('date_planned')
+ if dates_list:
+ order.date_planned = fields.Datetime.to_string(min(dates_list))
+ else:
+ order.date_planned = False
+
+ @api.depends('name', 'partner_ref')
+ def name_get(self):
+ result = []
+ for po in self:
+ name = po.name
+ if po.partner_ref:
+ name += ' (' + po.partner_ref + ')'
+ if self.env.context.get('show_total_amount') and po.amount_total:
+ name += ': ' + formatLang(self.env, po.amount_total, currency_obj=po.currency_id)
+ result.append((po.id, name))
+ return result
+
+ @api.onchange('date_planned')
+ def onchange_date_planned(self):
+ if self.date_planned:
+ self.order_line.filtered(lambda line: not line.display_type).date_planned = self.date_planned
+
+ def write(self, vals):
+ vals, partner_vals = self._write_partner_values(vals)
+ res = super().write(vals)
+ if partner_vals:
+ self.partner_id.sudo().write(partner_vals) # Because the purchase user doesn't have write on `res.partner`
+ return res
+
+ @api.model
+ def create(self, vals):
+ company_id = vals.get('company_id', self.default_get(['company_id'])['company_id'])
+ # Ensures default picking type and currency are taken from the right company.
+ self_comp = self.with_company(company_id)
+ if vals.get('name', 'New') == 'New':
+ seq_date = None
+ if 'date_order' in vals:
+ seq_date = fields.Datetime.context_timestamp(self, fields.Datetime.to_datetime(vals['date_order']))
+ vals['name'] = self_comp.env['ir.sequence'].next_by_code('purchase.order', sequence_date=seq_date) or '/'
+ vals, partner_vals = self._write_partner_values(vals)
+ res = super(PurchaseOrder, self_comp).create(vals)
+ if partner_vals:
+ res.sudo().write(partner_vals) # Because the purchase user doesn't have write on `res.partner`
+ return res
+
+ def unlink(self):
+ for order in self:
+ if not order.state == 'cancel':
+ raise UserError(_('In order to delete a purchase order, you must cancel it first.'))
+ return super(PurchaseOrder, self).unlink()
+
+ def copy(self, default=None):
+ ctx = dict(self.env.context)
+ ctx.pop('default_product_id', None)
+ self = self.with_context(ctx)
+ new_po = super(PurchaseOrder, self).copy(default=default)
+ for line in new_po.order_line:
+ if line.product_id:
+ seller = line.product_id._select_seller(
+ partner_id=line.partner_id, quantity=line.product_qty,
+ date=line.order_id.date_order and line.order_id.date_order.date(), uom_id=line.product_uom)
+ line.date_planned = line._get_date_planned(seller)
+ return new_po
+
+ def _must_delete_date_planned(self, field_name):
+ # To be overridden
+ return field_name == 'order_line'
+
+ def onchange(self, values, field_name, field_onchange):
+ """Override onchange to NOT to update all date_planned on PO lines when
+ date_planned on PO is updated by the change of date_planned on PO lines.
+ """
+ result = super(PurchaseOrder, self).onchange(values, field_name, field_onchange)
+ if self._must_delete_date_planned(field_name) and 'value' in result:
+ already_exist = [ol[1] for ol in values.get('order_line', []) if ol[1]]
+ for line in result['value'].get('order_line', []):
+ if line[0] < 2 and 'date_planned' in line[2] and line[1] in already_exist:
+ del line[2]['date_planned']
+ return result
+
+ def _track_subtype(self, init_values):
+ self.ensure_one()
+ if 'state' in init_values and self.state == 'purchase':
+ if init_values['state'] == 'to approve':
+ return self.env.ref('purchase.mt_rfq_approved')
+ return self.env.ref('purchase.mt_rfq_confirmed')
+ elif 'state' in init_values and self.state == 'to approve':
+ return self.env.ref('purchase.mt_rfq_confirmed')
+ elif 'state' in init_values and self.state == 'done':
+ return self.env.ref('purchase.mt_rfq_done')
+ return super(PurchaseOrder, self)._track_subtype(init_values)
+
+ def _get_report_base_filename(self):
+ self.ensure_one()
+ return 'Purchase Order-%s' % (self.name)
+
+ @api.onchange('partner_id', 'company_id')
+ def onchange_partner_id(self):
+ # Ensures all properties and fiscal positions
+ # are taken with the company of the order
+ # if not defined, with_company doesn't change anything.
+ self = self.with_company(self.company_id)
+ if not self.partner_id:
+ self.fiscal_position_id = False
+ self.currency_id = self.env.company.currency_id.id
+ else:
+ self.fiscal_position_id = self.env['account.fiscal.position'].get_fiscal_position(self.partner_id.id)
+ self.payment_term_id = self.partner_id.property_supplier_payment_term_id.id
+ self.currency_id = self.partner_id.property_purchase_currency_id.id or self.env.company.currency_id.id
+ return {}
+
+ @api.onchange('fiscal_position_id', 'company_id')
+ def _compute_tax_id(self):
+ """
+ Trigger the recompute of the taxes if the fiscal position is changed on the PO.
+ """
+ self.order_line._compute_tax_id()
+
+ @api.onchange('partner_id')
+ def onchange_partner_id_warning(self):
+ if not self.partner_id or not self.env.user.has_group('purchase.group_warning_purchase'):
+ return
+ warning = {}
+ title = False
+ message = False
+
+ partner = self.partner_id
+
+ # If partner has no warning, check its company
+ if partner.purchase_warn == 'no-message' and partner.parent_id:
+ partner = partner.parent_id
+
+ if partner.purchase_warn and partner.purchase_warn != 'no-message':
+ # Block if partner only has warning but parent company is blocked
+ if partner.purchase_warn != 'block' and partner.parent_id and partner.parent_id.purchase_warn == 'block':
+ partner = partner.parent_id
+ title = _("Warning for %s", partner.name)
+ message = partner.purchase_warn_msg
+ warning = {
+ 'title': title,
+ 'message': message
+ }
+ if partner.purchase_warn == 'block':
+ self.update({'partner_id': False})
+ return {'warning': warning}
+ return {}
+
+ def action_rfq_send(self):
+ '''
+ This function opens a window to compose an email, with the edi purchase template message loaded by default
+ '''
+ self.ensure_one()
+ ir_model_data = self.env['ir.model.data']
+ try:
+ if self.env.context.get('send_rfq', False):
+ template_id = ir_model_data.get_object_reference('purchase', 'email_template_edi_purchase')[1]
+ else:
+ template_id = ir_model_data.get_object_reference('purchase', 'email_template_edi_purchase_done')[1]
+ except ValueError:
+ template_id = False
+ try:
+ compose_form_id = ir_model_data.get_object_reference('mail', 'email_compose_message_wizard_form')[1]
+ except ValueError:
+ compose_form_id = False
+ ctx = dict(self.env.context or {})
+ ctx.update({
+ 'default_model': 'purchase.order',
+ 'active_model': 'purchase.order',
+ 'active_id': self.ids[0],
+ 'default_res_id': self.ids[0],
+ 'default_use_template': bool(template_id),
+ 'default_template_id': template_id,
+ 'default_composition_mode': 'comment',
+ 'custom_layout': "mail.mail_notification_paynow",
+ 'force_email': True,
+ 'mark_rfq_as_sent': True,
+ })
+
+ # In the case of a RFQ or a PO, we want the "View..." button in line with the state of the
+ # object. Therefore, we pass the model description in the context, in the language in which
+ # the template is rendered.
+ lang = self.env.context.get('lang')
+ if {'default_template_id', 'default_model', 'default_res_id'} <= ctx.keys():
+ template = self.env['mail.template'].browse(ctx['default_template_id'])
+ if template and template.lang:
+ lang = template._render_lang([ctx['default_res_id']])[ctx['default_res_id']]
+
+ self = self.with_context(lang=lang)
+ if self.state in ['draft', 'sent']:
+ ctx['model_description'] = _('Request for Quotation')
+ else:
+ ctx['model_description'] = _('Purchase Order')
+
+ return {
+ 'name': _('Compose Email'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.compose.message',
+ 'views': [(compose_form_id, 'form')],
+ 'view_id': compose_form_id,
+ 'target': 'new',
+ 'context': ctx,
+ }
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, **kwargs):
+ if self.env.context.get('mark_rfq_as_sent'):
+ self.filtered(lambda o: o.state == 'draft').write({'state': 'sent'})
+ return super(PurchaseOrder, self.with_context(mail_post_autofollow=True)).message_post(**kwargs)
+
+ def print_quotation(self):
+ self.write({'state': "sent"})
+ return self.env.ref('purchase.report_purchase_quotation').report_action(self)
+
+ def button_approve(self, force=False):
+ self = self.filtered(lambda order: order._approval_allowed())
+ self.write({'state': 'purchase', 'date_approve': fields.Datetime.now()})
+ self.filtered(lambda p: p.company_id.po_lock == 'lock').write({'state': 'done'})
+ return {}
+
+ def button_draft(self):
+ self.write({'state': 'draft'})
+ return {}
+
+ def button_confirm(self):
+ for order in self:
+ if order.state not in ['draft', 'sent']:
+ continue
+ order._add_supplier_to_product()
+ # Deal with double validation process
+ if order._approval_allowed():
+ order.button_approve()
+ else:
+ order.write({'state': 'to approve'})
+ if order.partner_id not in order.message_partner_ids:
+ order.message_subscribe([order.partner_id.id])
+ return True
+
+ def button_cancel(self):
+ for order in self:
+ for inv in order.invoice_ids:
+ if inv and inv.state not in ('cancel', 'draft'):
+ raise UserError(_("Unable to cancel this purchase order. You must first cancel the related vendor bills."))
+
+ self.write({'state': 'cancel'})
+
+ def button_unlock(self):
+ self.write({'state': 'purchase'})
+
+ def button_done(self):
+ self.write({'state': 'done', 'priority': '0'})
+
+ def _add_supplier_to_product(self):
+ # Add the partner in the supplier list of the product if the supplier is not registered for
+ # this product. We limit to 10 the number of suppliers for a product to avoid the mess that
+ # could be caused for some generic products ("Miscellaneous").
+ for line in self.order_line:
+ # Do not add a contact as a supplier
+ partner = self.partner_id if not self.partner_id.parent_id else self.partner_id.parent_id
+ if line.product_id and partner not in line.product_id.seller_ids.mapped('name') and len(line.product_id.seller_ids) <= 10:
+ # Convert the price in the right currency.
+ currency = partner.property_purchase_currency_id or self.env.company.currency_id
+ price = self.currency_id._convert(line.price_unit, currency, line.company_id, line.date_order or fields.Date.today(), round=False)
+ # Compute the price for the template's UoM, because the supplier's UoM is related to that UoM.
+ if line.product_id.product_tmpl_id.uom_po_id != line.product_uom:
+ default_uom = line.product_id.product_tmpl_id.uom_po_id
+ price = line.product_uom._compute_price(price, default_uom)
+
+ supplierinfo = {
+ 'name': partner.id,
+ 'sequence': max(line.product_id.seller_ids.mapped('sequence')) + 1 if line.product_id.seller_ids else 1,
+ 'min_qty': 0.0,
+ 'price': price,
+ 'currency_id': currency.id,
+ 'delay': 0,
+ }
+ # In case the order partner is a contact address, a new supplierinfo is created on
+ # the parent company. In this case, we keep the product name and code.
+ seller = line.product_id._select_seller(
+ partner_id=line.partner_id,
+ quantity=line.product_qty,
+ date=line.order_id.date_order and line.order_id.date_order.date(),
+ uom_id=line.product_uom)
+ if seller:
+ supplierinfo['product_name'] = seller.product_name
+ supplierinfo['product_code'] = seller.product_code
+ vals = {
+ 'seller_ids': [(0, 0, supplierinfo)],
+ }
+ try:
+ line.product_id.write(vals)
+ except AccessError: # no write access rights -> just ignore
+ break
+
+ def action_create_invoice(self):
+ """Create the invoice associated to the PO.
+ """
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+
+ # 1) Prepare invoice vals and clean-up the section lines
+ invoice_vals_list = []
+ for order in self:
+ if order.invoice_status != 'to invoice':
+ continue
+
+ order = order.with_company(order.company_id)
+ pending_section = None
+ # Invoice values.
+ invoice_vals = order._prepare_invoice()
+ # Invoice line values (keep only necessary sections).
+ for line in order.order_line:
+ if line.display_type == 'line_section':
+ pending_section = line
+ continue
+ if not float_is_zero(line.qty_to_invoice, precision_digits=precision):
+ if pending_section:
+ invoice_vals['invoice_line_ids'].append((0, 0, pending_section._prepare_account_move_line()))
+ pending_section = None
+ invoice_vals['invoice_line_ids'].append((0, 0, line._prepare_account_move_line()))
+ invoice_vals_list.append(invoice_vals)
+
+ if not invoice_vals_list:
+ raise UserError(_('There is no invoiceable line. If a product has a control policy based on received quantity, please make sure that a quantity has been received.'))
+
+ # 2) group by (company_id, partner_id, currency_id) for batch creation
+ new_invoice_vals_list = []
+ for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: (x.get('company_id'), x.get('partner_id'), x.get('currency_id'))):
+ origins = set()
+ payment_refs = set()
+ refs = set()
+ ref_invoice_vals = None
+ for invoice_vals in invoices:
+ if not ref_invoice_vals:
+ ref_invoice_vals = invoice_vals
+ else:
+ ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids']
+ origins.add(invoice_vals['invoice_origin'])
+ payment_refs.add(invoice_vals['payment_reference'])
+ refs.add(invoice_vals['ref'])
+ ref_invoice_vals.update({
+ 'ref': ', '.join(refs)[:2000],
+ 'invoice_origin': ', '.join(origins),
+ 'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False,
+ })
+ new_invoice_vals_list.append(ref_invoice_vals)
+ invoice_vals_list = new_invoice_vals_list
+
+ # 3) Create invoices.
+ moves = self.env['account.move']
+ AccountMove = self.env['account.move'].with_context(default_move_type='in_invoice')
+ for vals in invoice_vals_list:
+ moves |= AccountMove.with_company(vals['company_id']).create(vals)
+
+ # 4) Some moves might actually be refunds: convert them if the total amount is negative
+ # We do this after the moves have been created since we need taxes, etc. to know if the total
+ # is actually negative or not
+ moves.filtered(lambda m: m.currency_id.round(m.amount_total) < 0).action_switch_invoice_into_refund_credit_note()
+
+ return self.action_view_invoice(moves)
+
+ def _prepare_invoice(self):
+ """Prepare the dict of values to create the new invoice for a purchase order.
+ """
+ self.ensure_one()
+ move_type = self._context.get('default_move_type', 'in_invoice')
+ journal = self.env['account.move'].with_context(default_move_type=move_type)._get_default_journal()
+ if not journal:
+ raise UserError(_('Please define an accounting purchase journal for the company %s (%s).') % (self.company_id.name, self.company_id.id))
+
+ partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
+ invoice_vals = {
+ 'ref': self.partner_ref or '',
+ 'move_type': move_type,
+ 'narration': self.notes,
+ 'currency_id': self.currency_id.id,
+ 'invoice_user_id': self.user_id and self.user_id.id or self.env.user.id,
+ 'partner_id': partner_invoice_id,
+ 'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(partner_invoice_id)).id,
+ 'payment_reference': self.partner_ref or '',
+ 'partner_bank_id': self.partner_id.bank_ids[:1].id,
+ 'invoice_origin': self.name,
+ 'invoice_payment_term_id': self.payment_term_id.id,
+ 'invoice_line_ids': [],
+ 'company_id': self.company_id.id,
+ }
+ return invoice_vals
+
+ def action_view_invoice(self, invoices=False):
+ """This function returns an action that display existing vendor bills of
+ given purchase order ids. When only one found, show the vendor bill
+ immediately.
+ """
+ if not invoices:
+ # Invoice_ids may be filtered depending on the user. To ensure we get all
+ # invoices related to the purchase order, we read them in sudo to fill the
+ # cache.
+ self.sudo()._read(['invoice_ids'])
+ invoices = self.invoice_ids
+
+ result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_in_invoice_type')
+ # choose the view_mode accordingly
+ if len(invoices) > 1:
+ result['domain'] = [('id', 'in', invoices.ids)]
+ elif len(invoices) == 1:
+ res = self.env.ref('account.view_move_form', False)
+ form_view = [(res and res.id or False, 'form')]
+ if 'views' in result:
+ result['views'] = form_view + [(state, view) for state, view in result['views'] if view != 'form']
+ else:
+ result['views'] = form_view
+ result['res_id'] = invoices.id
+ else:
+ result = {'type': 'ir.actions.act_window_close'}
+
+ return result
+
+ @api.model
+ def retrieve_dashboard(self):
+ """ This function returns the values to populate the custom dashboard in
+ the purchase order views.
+ """
+ self.check_access_rights('read')
+
+ result = {
+ 'all_to_send': 0,
+ 'all_waiting': 0,
+ 'all_late': 0,
+ 'my_to_send': 0,
+ 'my_waiting': 0,
+ 'my_late': 0,
+ 'all_avg_order_value': 0,
+ 'all_avg_days_to_purchase': 0,
+ 'all_total_last_7_days': 0,
+ 'all_sent_rfqs': 0,
+ 'company_currency_symbol': self.env.company.currency_id.symbol
+ }
+
+ one_week_ago = fields.Datetime.to_string(fields.Datetime.now() - relativedelta(days=7))
+ # This query is brittle since it depends on the label values of a selection field
+ # not changing, but we don't have a direct time tracker of when a state changes
+ query = """SELECT COUNT(1)
+ FROM mail_tracking_value v
+ LEFT JOIN mail_message m ON (v.mail_message_id = m.id)
+ JOIN purchase_order po ON (po.id = m.res_id)
+ WHERE m.create_date >= %s
+ AND m.model = 'purchase.order'
+ AND m.message_type = 'notification'
+ AND v.old_value_char = 'RFQ'
+ AND v.new_value_char = 'RFQ Sent'
+ AND po.company_id = %s;
+ """
+
+ self.env.cr.execute(query, (one_week_ago, self.env.company.id))
+ res = self.env.cr.fetchone()
+ result['all_sent_rfqs'] = res[0] or 0
+
+ # easy counts
+ po = self.env['purchase.order']
+ result['all_to_send'] = po.search_count([('state', '=', 'draft')])
+ result['my_to_send'] = po.search_count([('state', '=', 'draft'), ('user_id', '=', self.env.uid)])
+ result['all_waiting'] = po.search_count([('state', '=', 'sent'), ('date_order', '>=', fields.Datetime.now())])
+ result['my_waiting'] = po.search_count([('state', '=', 'sent'), ('date_order', '>=', fields.Datetime.now()), ('user_id', '=', self.env.uid)])
+ result['all_late'] = po.search_count([('state', 'in', ['draft', 'sent', 'to approve']), ('date_order', '<', fields.Datetime.now())])
+ result['my_late'] = po.search_count([('state', 'in', ['draft', 'sent', 'to approve']), ('date_order', '<', fields.Datetime.now()), ('user_id', '=', self.env.uid)])
+
+ # Calculated values ('avg order value', 'avg days to purchase', and 'total last 7 days') note that 'avg order value' and
+ # 'total last 7 days' takes into account exchange rate and current company's currency's precision. Min of currency precision
+ # is taken to easily extract it from query.
+ # This is done via SQL for scalability reasons
+ query = """SELECT AVG(COALESCE(po.amount_total / NULLIF(po.currency_rate, 0), po.amount_total)),
+ AVG(extract(epoch from age(po.date_approve,po.create_date)/(24*60*60)::decimal(16,2))),
+ SUM(CASE WHEN po.date_approve >= %s THEN COALESCE(po.amount_total / NULLIF(po.currency_rate, 0), po.amount_total) ELSE 0 END),
+ MIN(curr.decimal_places)
+ FROM purchase_order po
+ JOIN res_company comp ON (po.company_id = comp.id)
+ JOIN res_currency curr ON (comp.currency_id = curr.id)
+ WHERE po.state in ('purchase', 'done')
+ AND po.company_id = %s
+ """
+ self._cr.execute(query, (one_week_ago, self.env.company.id))
+ res = self.env.cr.fetchone()
+ result['all_avg_order_value'] = round(res[0] or 0, res[3])
+ result['all_avg_days_to_purchase'] = round(res[1] or 0, 2)
+ result['all_total_last_7_days'] = round(res[2] or 0, res[3])
+
+ return result
+
+ def _send_reminder_mail(self, send_single=False):
+ if not self.user_has_groups('purchase.group_send_reminder'):
+ return
+
+ template = self.env.ref('purchase.email_template_edi_purchase_reminder', raise_if_not_found=False)
+ if template:
+ orders = self if send_single else self._get_orders_to_remind()
+ for order in orders:
+ date = order.date_planned
+ if date and (send_single or (date - relativedelta(days=order.reminder_date_before_receipt)).date() == datetime.today().date()):
+ order.with_context(is_reminder=True).message_post_with_template(template.id, email_layout_xmlid="mail.mail_notification_paynow", composition_mode='comment')
+
+ def send_reminder_preview(self):
+ self.ensure_one()
+ if not self.user_has_groups('purchase.group_send_reminder'):
+ return
+
+ template = self.env.ref('purchase.email_template_edi_purchase_reminder', raise_if_not_found=False)
+ if template and self.env.user.email and self.id:
+ template.with_context(is_reminder=True).send_mail(
+ self.id,
+ force_send=True,
+ raise_exception=False,
+ email_values={'email_to': self.env.user.email, 'recipient_ids': []},
+ notif_layout="mail.mail_notification_paynow")
+ return {'toast_message': _("A sample email has been sent to %s.") % self.env.user.email}
+
+ @api.model
+ def _get_orders_to_remind(self):
+ """When auto sending a reminder mail, only send for unconfirmed purchase
+ order and not all products are service."""
+ return self.search([
+ ('receipt_reminder_email', '=', True),
+ ('state', 'in', ['purchase', 'done']),
+ ('mail_reminder_confirmed', '=', False)
+ ]).filtered(lambda p: p.mapped('order_line.product_id.product_tmpl_id.type') != ['service'])
+
+ def get_confirm_url(self, confirm_type=None):
+ """Create url for confirm reminder or purchase reception email for sending
+ in mail."""
+ if confirm_type in ['reminder', 'reception']:
+ param = url_encode({
+ 'confirm': confirm_type,
+ 'confirmed_date': self.date_planned and self.date_planned.date(),
+ })
+ return self.get_portal_url(query_string='&%s' % param)
+ return self.get_portal_url()
+
+ def get_update_url(self):
+ """Create portal url for user to update the scheduled date on purchase
+ order lines."""
+ update_param = url_encode({'update': 'True'})
+ return self.get_portal_url(query_string='&%s' % update_param)
+
+ def confirm_reminder_mail(self, confirmed_date=False):
+ for order in self:
+ if order.state in ['purchase', 'done'] and not order.mail_reminder_confirmed:
+ order.mail_reminder_confirmed = True
+ date = confirmed_date or self.date_planned.date()
+ order.message_post(body=_("%(name)s confirmed the receipt will take place on %(date)s.", name=order.partner_id.name, date=date))
+
+ def _approval_allowed(self):
+ """Returns whether the order qualifies to be approved by the current user"""
+ self.ensure_one()
+ return (
+ self.company_id.po_double_validation == 'one_step'
+ or (self.company_id.po_double_validation == 'two_step'
+ and self.amount_total < self.env.company.currency_id._convert(
+ self.company_id.po_double_validation_amount, self.currency_id, self.company_id,
+ self.date_order or fields.Date.today()))
+ or self.user_has_groups('purchase.group_purchase_manager'))
+
+ def _confirm_reception_mail(self):
+ for order in self:
+ if order.state in ['purchase', 'done'] and not order.mail_reception_confirmed:
+ order.mail_reception_confirmed = True
+ order.message_post(body=_("The order receipt has been acknowledged by %(name)s.", name=order.partner_id.name))
+
+ def _update_date_planned_for_lines(self, updated_dates):
+ # create or update the activity
+ activity = self.env['mail.activity'].search([
+ ('summary', '=', _('Date Updated')),
+ ('res_model_id', '=', 'purchase.order'),
+ ('res_id', '=', self.id),
+ ('user_id', '=', self.user_id.id)], limit=1)
+ if activity:
+ self._update_update_date_activity(updated_dates, activity)
+ else:
+ self._create_update_date_activity(updated_dates)
+
+ # update the date on PO line
+ for line, date in updated_dates:
+ line._update_date_planned(date)
+
+ def _create_update_date_activity(self, updated_dates):
+ note = _('<p> %s modified receipt dates for the following products:</p>') % self.partner_id.name
+ for line, date in updated_dates:
+ note += _('<p> &nbsp; - %s from %s to %s </p>') % (line.product_id.display_name, line.date_planned.date(), date.date())
+ activity = self.activity_schedule(
+ 'mail.mail_activity_data_warning',
+ summary=_("Date Updated"),
+ user_id=self.user_id.id
+ )
+ # add the note after we post the activity because the note can be soon
+ # changed when updating the date of the next PO line. So instead of
+ # sending a mail with incomplete note, we send one with no note.
+ activity.note = note
+ return activity
+
+ def _update_update_date_activity(self, updated_dates, activity):
+ for line, date in updated_dates:
+ activity.note += _('<p> &nbsp; - %s from %s to %s </p>') % (line.product_id.display_name, line.date_planned.date(), date.date())
+
+ def _write_partner_values(self, vals):
+ partner_values = {}
+ if 'receipt_reminder_email' in vals:
+ partner_values['receipt_reminder_email'] = vals.pop('receipt_reminder_email')
+ if 'reminder_date_before_receipt' in vals:
+ partner_values['reminder_date_before_receipt'] = vals.pop('reminder_date_before_receipt')
+ return vals, partner_values
+
+
+class PurchaseOrderLine(models.Model):
+ _name = 'purchase.order.line'
+ _description = 'Purchase Order Line'
+ _order = 'order_id, sequence, id'
+
+ name = fields.Text(string='Description', required=True)
+ sequence = fields.Integer(string='Sequence', default=10)
+ product_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True)
+ product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True)
+ date_planned = fields.Datetime(string='Delivery Date', index=True,
+ help="Delivery date expected from vendor. This date respectively defaults to vendor pricelist lead time then today's date.")
+ taxes_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)])
+ product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]")
+ product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
+ product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True)
+ product_type = fields.Selection(related='product_id.type', readonly=True)
+ price_unit = fields.Float(string='Unit Price', required=True, digits='Product Price')
+
+ price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', store=True)
+ price_total = fields.Monetary(compute='_compute_amount', string='Total', store=True)
+ price_tax = fields.Float(compute='_compute_amount', string='Tax', store=True)
+
+ order_id = fields.Many2one('purchase.order', string='Order Reference', index=True, required=True, ondelete='cascade')
+ account_analytic_id = fields.Many2one('account.analytic.account', store=True, string='Analytic Account', compute='_compute_analytic_id_and_tag_ids', readonly=False)
+ analytic_tag_ids = fields.Many2many('account.analytic.tag', store=True, string='Analytic Tags', compute='_compute_analytic_id_and_tag_ids', readonly=False)
+ company_id = fields.Many2one('res.company', related='order_id.company_id', string='Company', store=True, readonly=True)
+ state = fields.Selection(related='order_id.state', store=True, readonly=False)
+
+ invoice_lines = fields.One2many('account.move.line', 'purchase_line_id', string="Bill Lines", readonly=True, copy=False)
+
+ # Replace by invoiced Qty
+ qty_invoiced = fields.Float(compute='_compute_qty_invoiced', string="Billed Qty", digits='Product Unit of Measure', store=True)
+
+ qty_received_method = fields.Selection([('manual', 'Manual')], string="Received Qty Method", compute='_compute_qty_received_method', store=True,
+ help="According to product configuration, the received quantity can be automatically computed by mechanism :\n"
+ " - Manual: the quantity is set manually on the line\n"
+ " - Stock Moves: the quantity comes from confirmed pickings\n")
+ qty_received = fields.Float("Received Qty", compute='_compute_qty_received', inverse='_inverse_qty_received', compute_sudo=True, store=True, digits='Product Unit of Measure')
+ qty_received_manual = fields.Float("Manual Received Qty", digits='Product Unit of Measure', copy=False)
+ qty_to_invoice = fields.Float(compute='_compute_qty_invoiced', string='To Invoice Quantity', store=True, readonly=True,
+ digits='Product Unit of Measure')
+
+ partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True)
+ currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True)
+ date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True)
+
+ display_type = fields.Selection([
+ ('line_section', "Section"),
+ ('line_note', "Note")], default=False, help="Technical field for UX purpose.")
+
+ _sql_constraints = [
+ ('accountable_required_fields',
+ "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL AND date_planned IS NOT NULL))",
+ "Missing required fields on accountable purchase order line."),
+ ('non_accountable_null_fields',
+ "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND date_planned is NULL))",
+ "Forbidden values on non-accountable purchase order line"),
+ ]
+
+ @api.depends('product_qty', 'price_unit', 'taxes_id')
+ def _compute_amount(self):
+ for line in self:
+ vals = line._prepare_compute_all_values()
+ taxes = line.taxes_id.compute_all(
+ vals['price_unit'],
+ vals['currency_id'],
+ vals['product_qty'],
+ vals['product'],
+ vals['partner'])
+ line.update({
+ 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])),
+ 'price_total': taxes['total_included'],
+ 'price_subtotal': taxes['total_excluded'],
+ })
+
+ def _prepare_compute_all_values(self):
+ # Hook method to returns the different argument values for the
+ # compute_all method, due to the fact that discounts mechanism
+ # is not implemented yet on the purchase orders.
+ # This method should disappear as soon as this feature is
+ # also introduced like in the sales module.
+ self.ensure_one()
+ return {
+ 'price_unit': self.price_unit,
+ 'currency_id': self.order_id.currency_id,
+ 'product_qty': self.product_qty,
+ 'product': self.product_id,
+ 'partner': self.order_id.partner_id,
+ }
+
+ def _compute_tax_id(self):
+ for line in self:
+ line = line.with_company(line.company_id)
+ fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_id.partner_id.id)
+ # filter taxes by company
+ taxes = line.product_id.supplier_taxes_id.filtered(lambda r: r.company_id == line.env.company)
+ line.taxes_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_id)
+
+ @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'qty_received', 'product_uom_qty', 'order_id.state')
+ def _compute_qty_invoiced(self):
+ for line in self:
+ # compute qty_invoiced
+ qty = 0.0
+ for inv_line in line.invoice_lines:
+ if inv_line.move_id.state not in ['cancel']:
+ if inv_line.move_id.move_type == 'in_invoice':
+ qty += inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom)
+ elif inv_line.move_id.move_type == 'in_refund':
+ qty -= inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom)
+ line.qty_invoiced = qty
+
+ # compute qty_to_invoice
+ if line.order_id.state in ['purchase', 'done']:
+ if line.product_id.purchase_method == 'purchase':
+ line.qty_to_invoice = line.product_qty - line.qty_invoiced
+ else:
+ line.qty_to_invoice = line.qty_received - line.qty_invoiced
+ else:
+ line.qty_to_invoice = 0
+
+ @api.depends('product_id')
+ def _compute_qty_received_method(self):
+ for line in self:
+ if line.product_id and line.product_id.type in ['consu', 'service']:
+ line.qty_received_method = 'manual'
+ else:
+ line.qty_received_method = False
+
+ @api.depends('qty_received_method', 'qty_received_manual')
+ def _compute_qty_received(self):
+ for line in self:
+ if line.qty_received_method == 'manual':
+ line.qty_received = line.qty_received_manual or 0.0
+ else:
+ line.qty_received = 0.0
+
+ @api.onchange('qty_received')
+ def _inverse_qty_received(self):
+ """ When writing on qty_received, if the value should be modify manually (`qty_received_method` = 'manual' only),
+ then we put the value in `qty_received_manual`. Otherwise, `qty_received_manual` should be False since the
+ received qty is automatically compute by other mecanisms.
+ """
+ for line in self:
+ if line.qty_received_method == 'manual':
+ line.qty_received_manual = line.qty_received
+ else:
+ line.qty_received_manual = 0.0
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for values in vals_list:
+ if values.get('display_type', self.default_get(['display_type'])['display_type']):
+ values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, date_planned=False)
+ else:
+ values.update(self._prepare_add_missing_fields(values))
+
+ lines = super().create(vals_list)
+ for line in lines:
+ if line.product_id and line.order_id.state == 'purchase':
+ msg = _("Extra line with %s ") % (line.product_id.display_name,)
+ line.order_id.message_post(body=msg)
+ return lines
+
+ def write(self, values):
+ if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')):
+ raise UserError(_("You cannot change the type of a purchase order line. Instead you should delete the current line and create a new line of the proper type."))
+
+ if 'product_qty' in values:
+ for line in self:
+ if line.order_id.state == 'purchase':
+ line.order_id.message_post_with_view('purchase.track_po_line_template',
+ values={'line': line, 'product_qty': values['product_qty']},
+ subtype_id=self.env.ref('mail.mt_note').id)
+ if 'qty_received' in values:
+ for line in self:
+ line._track_qty_received(values['qty_received'])
+ return super(PurchaseOrderLine, self).write(values)
+
+ def unlink(self):
+ for line in self:
+ if line.order_id.state in ['purchase', 'done']:
+ raise UserError(_('Cannot delete a purchase order line which is in state \'%s\'.') % (line.state,))
+ return super(PurchaseOrderLine, self).unlink()
+
+ @api.model
+ def _get_date_planned(self, seller, po=False):
+ """Return the datetime value to use as Schedule Date (``date_planned``) for
+ PO Lines that correspond to the given product.seller_ids,
+ when ordered at `date_order_str`.
+
+ :param Model seller: used to fetch the delivery delay (if no seller
+ is provided, the delay is 0)
+ :param Model po: purchase.order, necessary only if the PO line is
+ not yet attached to a PO.
+ :rtype: datetime
+ :return: desired Schedule Date for the PO line
+ """
+ date_order = po.date_order if po else self.order_id.date_order
+ if date_order:
+ date_planned = date_order + relativedelta(days=seller.delay if seller else 0)
+ else:
+ date_planned = datetime.today() + relativedelta(days=seller.delay if seller else 0)
+ return self._convert_to_middle_of_day(date_planned)
+
+ @api.depends('product_id', 'date_order')
+ def _compute_analytic_id_and_tag_ids(self):
+ for rec in self:
+ default_analytic_account = rec.env['account.analytic.default'].sudo().account_get(
+ product_id=rec.product_id.id,
+ partner_id=rec.order_id.partner_id.id,
+ user_id=rec.env.uid,
+ date=rec.date_order,
+ company_id=rec.company_id.id,
+ )
+ rec.account_analytic_id = rec.account_analytic_id or default_analytic_account.analytic_id
+ rec.analytic_tag_ids = rec.analytic_tag_ids or default_analytic_account.analytic_tag_ids
+
+ @api.onchange('product_id')
+ def onchange_product_id(self):
+ if not self.product_id:
+ return
+
+ # Reset date, price and quantity since _onchange_quantity will provide default values
+ self.price_unit = self.product_qty = 0.0
+
+ self._product_id_change()
+
+ self._suggest_quantity()
+ self._onchange_quantity()
+
+ def _product_id_change(self):
+ if not self.product_id:
+ return
+
+ self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id
+ product_lang = self.product_id.with_context(
+ lang=get_lang(self.env, self.partner_id.lang).code,
+ partner_id=self.partner_id.id,
+ company_id=self.company_id.id,
+ )
+ self.name = self._get_product_purchase_description(product_lang)
+
+ self._compute_tax_id()
+
+ @api.onchange('product_id')
+ def onchange_product_id_warning(self):
+ if not self.product_id or not self.env.user.has_group('purchase.group_warning_purchase'):
+ return
+ warning = {}
+ title = False
+ message = False
+
+ product_info = self.product_id
+
+ if product_info.purchase_line_warn != 'no-message':
+ title = _("Warning for %s", product_info.name)
+ message = product_info.purchase_line_warn_msg
+ warning['title'] = title
+ warning['message'] = message
+ if product_info.purchase_line_warn == 'block':
+ self.product_id = False
+ return {'warning': warning}
+ return {}
+
+ @api.onchange('product_qty', 'product_uom')
+ def _onchange_quantity(self):
+ if not self.product_id:
+ return
+ params = {'order_id': self.order_id}
+ seller = self.product_id._select_seller(
+ partner_id=self.partner_id,
+ quantity=self.product_qty,
+ date=self.order_id.date_order and self.order_id.date_order.date(),
+ uom_id=self.product_uom,
+ params=params)
+
+ if seller or not self.date_planned:
+ self.date_planned = self._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+
+ # If not seller, use the standard price. It needs a proper currency conversion.
+ if not seller:
+ po_line_uom = self.product_uom or self.product_id.uom_po_id
+ price_unit = self.env['account.tax']._fix_tax_included_price_company(
+ self.product_id.uom_id._compute_price(self.product_id.standard_price, po_line_uom),
+ self.product_id.supplier_taxes_id,
+ self.taxes_id,
+ self.company_id,
+ )
+ if price_unit and self.order_id.currency_id and self.order_id.company_id.currency_id != self.order_id.currency_id:
+ price_unit = self.order_id.company_id.currency_id._convert(
+ price_unit,
+ self.order_id.currency_id,
+ self.order_id.company_id,
+ self.date_order or fields.Date.today(),
+ )
+
+ self.price_unit = price_unit
+ return
+
+ price_unit = self.env['account.tax']._fix_tax_included_price_company(seller.price, self.product_id.supplier_taxes_id, self.taxes_id, self.company_id) if seller else 0.0
+ if price_unit and seller and self.order_id.currency_id and seller.currency_id != self.order_id.currency_id:
+ price_unit = seller.currency_id._convert(
+ price_unit, self.order_id.currency_id, self.order_id.company_id, self.date_order or fields.Date.today())
+
+ if seller and self.product_uom and seller.product_uom != self.product_uom:
+ price_unit = seller.product_uom._compute_price(price_unit, self.product_uom)
+
+ self.price_unit = price_unit
+
+ @api.depends('product_uom', 'product_qty', 'product_id.uom_id')
+ def _compute_product_uom_qty(self):
+ for line in self:
+ if line.product_id and line.product_id.uom_id != line.product_uom:
+ line.product_uom_qty = line.product_uom._compute_quantity(line.product_qty, line.product_id.uom_id)
+ else:
+ line.product_uom_qty = line.product_qty
+
+ def _suggest_quantity(self):
+ '''
+ Suggest a minimal quantity based on the seller
+ '''
+ if not self.product_id:
+ return
+ seller_min_qty = self.product_id.seller_ids\
+ .filtered(lambda r: r.name == self.order_id.partner_id and (not r.product_id or r.product_id == self.product_id))\
+ .sorted(key=lambda r: r.min_qty)
+ if seller_min_qty:
+ self.product_qty = seller_min_qty[0].min_qty or 1.0
+ self.product_uom = seller_min_qty[0].product_uom
+ else:
+ self.product_qty = 1.0
+
+ def _get_product_purchase_description(self, product_lang):
+ self.ensure_one()
+ name = product_lang.display_name
+ if product_lang.description_purchase:
+ name += '\n' + product_lang.description_purchase
+
+ return name
+
+ def _prepare_account_move_line(self, move=False):
+ self.ensure_one()
+ aml_currency = move and move.currency_id or self.currency_id
+ date = move and move.date or fields.Date.today()
+ res = {
+ 'display_type': self.display_type,
+ 'sequence': self.sequence,
+ 'name': '%s: %s' % (self.order_id.name, self.name),
+ 'product_id': self.product_id.id,
+ 'product_uom_id': self.product_uom.id,
+ 'quantity': self.qty_to_invoice,
+ 'price_unit': self.currency_id._convert(self.price_unit, aml_currency, self.company_id, date, round=False),
+ 'tax_ids': [(6, 0, self.taxes_id.ids)],
+ 'analytic_account_id': self.account_analytic_id.id,
+ 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)],
+ 'purchase_line_id': self.id,
+ }
+ if not move:
+ return res
+
+ if self.currency_id == move.company_id.currency_id:
+ currency = False
+ else:
+ currency = move.currency_id
+
+ res.update({
+ 'move_id': move.id,
+ 'currency_id': currency and currency.id or False,
+ 'date_maturity': move.invoice_date_due,
+ 'partner_id': move.partner_id.id,
+ })
+ return res
+
+ @api.model
+ def _prepare_add_missing_fields(self, values):
+ """ Deduce missing required fields from the onchange """
+ res = {}
+ onchange_fields = ['name', 'price_unit', 'product_qty', 'product_uom', 'taxes_id', 'date_planned']
+ if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields):
+ line = self.new(values)
+ line.onchange_product_id()
+ for field in onchange_fields:
+ if field not in values:
+ res[field] = line._fields[field].convert_to_write(line[field], line)
+ return res
+
+ @api.model
+ def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, supplier, po):
+ partner = supplier.name
+ uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id)
+ # _select_seller is used if the supplier have different price depending
+ # the quantities ordered.
+ seller = product_id.with_company(company_id)._select_seller(
+ partner_id=partner,
+ quantity=uom_po_qty,
+ date=po.date_order and po.date_order.date(),
+ uom_id=product_id.uom_po_id)
+
+ taxes = product_id.supplier_taxes_id
+ fpos = po.fiscal_position_id
+ taxes_id = fpos.map_tax(taxes, product_id, seller.name)
+ if taxes_id:
+ taxes_id = taxes_id.filtered(lambda x: x.company_id.id == company_id.id)
+
+ price_unit = self.env['account.tax']._fix_tax_included_price_company(seller.price, product_id.supplier_taxes_id, taxes_id, company_id) if seller else 0.0
+ if price_unit and seller and po.currency_id and seller.currency_id != po.currency_id:
+ price_unit = seller.currency_id._convert(
+ price_unit, po.currency_id, po.company_id, po.date_order or fields.Date.today())
+
+ product_lang = product_id.with_prefetch().with_context(
+ lang=partner.lang,
+ partner_id=partner.id,
+ )
+ name = product_lang.display_name
+ if product_lang.description_purchase:
+ name += '\n' + product_lang.description_purchase
+
+ date_planned = self.order_id.date_planned or self._get_date_planned(seller, po=po)
+
+ return {
+ 'name': name,
+ 'product_qty': uom_po_qty,
+ 'product_id': product_id.id,
+ 'product_uom': product_id.uom_po_id.id,
+ 'price_unit': price_unit,
+ 'date_planned': date_planned,
+ 'taxes_id': [(6, 0, taxes_id.ids)],
+ 'order_id': po.id,
+ }
+
+ def _convert_to_middle_of_day(self, date):
+ """Return a datetime which is the noon of the input date(time) according
+ to order user's time zone, convert to UTC time.
+ """
+ return timezone(self.order_id.user_id.tz or self.company_id.partner_id.tz or 'UTC').localize(datetime.combine(date, time(12))).astimezone(UTC).replace(tzinfo=None)
+
+ def _update_date_planned(self, updated_date):
+ self.date_planned = updated_date
+
+ def _track_qty_received(self, new_qty):
+ self.ensure_one()
+ if new_qty != self.qty_received and self.order_id.state == 'purchase':
+ self.order_id.message_post_with_view(
+ 'purchase.track_po_line_qty_received_template',
+ values={'line': self, 'qty_received': new_qty},
+ subtype_id=self.env.ref('mail.mt_note').id
+ )
diff --git a/addons/purchase/models/res_company.py b/addons/purchase/models/res_company.py
new file mode 100644
index 00000000..49768923
--- /dev/null
+++ b/addons/purchase/models/res_company.py
@@ -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 Company(models.Model):
+ _inherit = 'res.company'
+
+ po_lead = fields.Float(string='Purchase Lead Time', required=True,
+ help="Margin of error for vendor lead times. When the system "
+ "generates Purchase Orders for procuring products, "
+ "they will be scheduled that many days earlier "
+ "to cope with unexpected vendor delays.", default=0.0)
+
+ po_lock = fields.Selection([
+ ('edit', 'Allow to edit purchase orders'),
+ ('lock', 'Confirmed purchase orders are not editable')
+ ], string="Purchase Order Modification", default="edit",
+ help='Purchase Order Modification used when you want to purchase order editable after confirm')
+
+ po_double_validation = fields.Selection([
+ ('one_step', 'Confirm purchase orders in one step'),
+ ('two_step', 'Get 2 levels of approvals to confirm a purchase order')
+ ], string="Levels of Approvals", default='one_step',
+ help="Provide a double validation mechanism for purchases")
+
+ po_double_validation_amount = fields.Monetary(string='Double validation amount', default=5000,
+ help="Minimum amount for which a double validation is required")
diff --git a/addons/purchase/models/res_config_settings.py b/addons/purchase/models/res_config_settings.py
new file mode 100644
index 00000000..f19b32cb
--- /dev/null
+++ b/addons/purchase/models/res_config_settings.py
@@ -0,0 +1,44 @@
+# -*- 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'
+
+ lock_confirmed_po = fields.Boolean("Lock Confirmed Orders", default=lambda self: self.env.company.po_lock == 'lock')
+ po_lock = fields.Selection(related='company_id.po_lock', string="Purchase Order Modification *", readonly=False)
+ po_order_approval = fields.Boolean("Purchase Order Approval", default=lambda self: self.env.company.po_double_validation == 'two_step')
+ po_double_validation = fields.Selection(related='company_id.po_double_validation', string="Levels of Approvals *", readonly=False)
+ po_double_validation_amount = fields.Monetary(related='company_id.po_double_validation_amount', string="Minimum Amount", currency_field='company_currency_id', readonly=False)
+ company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string="Company Currency", readonly=True,
+ help='Utility field to express amount currency')
+ default_purchase_method = fields.Selection([
+ ('purchase', 'Ordered quantities'),
+ ('receive', 'Received quantities'),
+ ], string="Bill Control", default_model="product.template",
+ help="This default value is applied to any new product created. "
+ "This can be changed in the product detail form.", default="receive")
+ group_warning_purchase = fields.Boolean("Purchase Warnings", implied_group='purchase.group_warning_purchase')
+ module_account_3way_match = fields.Boolean("3-way matching: purchases, receptions and bills")
+ module_purchase_requisition = fields.Boolean("Purchase Agreements")
+ module_purchase_product_matrix = fields.Boolean("Purchase Grid Entry")
+ po_lead = fields.Float(related='company_id.po_lead', readonly=False)
+ use_po_lead = fields.Boolean(
+ string="Security Lead Time for Purchase",
+ config_parameter='purchase.use_po_lead',
+ help="Margin of error for vendor lead times. When the system generates Purchase Orders for reordering products,they will be scheduled that many days earlier to cope with unexpected vendor delays.")
+
+ group_send_reminder = fields.Boolean("Receipt Reminder", implied_group='purchase.group_send_reminder', default=True,
+ help="Allow automatically send email to remind your vendor the receipt date")
+
+ @api.onchange('use_po_lead')
+ def _onchange_use_po_lead(self):
+ if not self.use_po_lead:
+ self.po_lead = 0.0
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+ self.po_lock = 'lock' if self.lock_confirmed_po else 'edit'
+ self.po_double_validation = 'two_step' if self.po_order_approval else 'one_step'
diff --git a/addons/purchase/models/res_partner.py b/addons/purchase/models/res_partner.py
new file mode 100644
index 00000000..8267e5a5
--- /dev/null
+++ b/addons/purchase/models/res_partner.py
@@ -0,0 +1,66 @@
+# -*- 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
+
+
+class res_partner(models.Model):
+ _name = 'res.partner'
+ _inherit = 'res.partner'
+
+ def _compute_purchase_order_count(self):
+ # retrieve all children partners and prefetch 'parent_id' on them
+ all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
+ all_partners.read(['parent_id'])
+
+ purchase_order_groups = self.env['purchase.order'].read_group(
+ domain=[('partner_id', 'in', all_partners.ids)],
+ fields=['partner_id'], groupby=['partner_id']
+ )
+ partners = self.browse()
+ for group in purchase_order_groups:
+ partner = self.browse(group['partner_id'][0])
+ while partner:
+ if partner in self:
+ partner.purchase_order_count += group['partner_id_count']
+ partners |= partner
+ partner = partner.parent_id
+ (self - partners).purchase_order_count = 0
+
+ def _compute_supplier_invoice_count(self):
+ # retrieve all children partners and prefetch 'parent_id' on them
+ all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
+ all_partners.read(['parent_id'])
+
+ supplier_invoice_groups = self.env['account.move'].read_group(
+ domain=[('partner_id', 'in', all_partners.ids),
+ ('move_type', 'in', ('in_invoice', 'in_refund'))],
+ fields=['partner_id'], groupby=['partner_id']
+ )
+ partners = self.browse()
+ for group in supplier_invoice_groups:
+ partner = self.browse(group['partner_id'][0])
+ while partner:
+ if partner in self:
+ partner.supplier_invoice_count += group['partner_id_count']
+ partners |= partner
+ partner = partner.parent_id
+ (self - partners).supplier_invoice_count = 0
+
+ @api.model
+ def _commercial_fields(self):
+ return super(res_partner, self)._commercial_fields()
+
+ property_purchase_currency_id = fields.Many2one(
+ 'res.currency', string="Supplier Currency", company_dependent=True,
+ help="This currency will be used, instead of the default one, for purchases from the current partner")
+ purchase_order_count = fields.Integer(compute='_compute_purchase_order_count', string='Purchase Order Count')
+ supplier_invoice_count = fields.Integer(compute='_compute_supplier_invoice_count', string='# Vendor Bills')
+ purchase_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order', help=WARNING_HELP, default="no-message")
+ purchase_warn_msg = fields.Text('Message for Purchase Order')
+
+ receipt_reminder_email = fields.Boolean('Receipt Reminder', default=False, company_dependent=True,
+ help="Automatically send a confirmation email to the vendor X days before the expected receipt date, asking him to confirm the exact date.")
+ reminder_date_before_receipt = fields.Integer('Days Before Receipt', default=1, company_dependent=True,
+ help="Number of days to send reminder email before the promised receipt date")