summaryrefslogtreecommitdiff
path: root/addons/purchase/models/purchase.py
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/purchase.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/purchase/models/purchase.py')
-rw-r--r--addons/purchase/models/purchase.py1235
1 files changed, 1235 insertions, 0 deletions
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
+ )