# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from datetime import datetime from dateutil.relativedelta import relativedelta from flectra import api, fields, models, SUPERUSER_ID, _ from flectra.tools import DEFAULT_SERVER_DATETIME_FORMAT from flectra.tools.float_utils import float_is_zero, float_compare from flectra.exceptions import UserError, AccessError from flectra.tools.misc import formatLang from flectra.addons.base.res.res_partner import WARNING_MESSAGE, WARNING_HELP from flectra.addons import decimal_precision as dp class PurchaseOrder(models.Model): _name = "purchase.order" _inherit = ['mail.thread', 'mail.activity.mixin', 'ir.branch.company.mixin'] _description = "Purchase Order" _order = 'date_order 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: amount_untaxed += line.price_subtotal amount_tax += line.price_tax order.update({ 'amount_untaxed': order.currency_id.round(amount_untaxed), 'amount_tax': order.currency_id.round(amount_tax), 'amount_total': amount_untaxed + amount_tax, }) @api.depends('order_line.date_planned') def _compute_date_planned(self): for order in self: min_date = False for line in order.order_line: if not min_date or line.date_planned < min_date: min_date = line.date_planned if min_date: order.date_planned = min_date @api.depends('state', 'order_line.qty_invoiced', 'order_line.qty_received', 'order_line.product_qty') 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(float_compare(line.qty_invoiced, line.product_qty if line.product_id.purchase_method == 'purchase' else line.qty_received, precision_digits=precision) == -1 for line in order.order_line): order.invoice_status = 'to invoice' elif all(float_compare(line.qty_invoiced, line.product_qty if line.product_id.purchase_method == 'purchase' else line.qty_received, precision_digits=precision) >= 0 for line in order.order_line) and order.invoice_ids: order.invoice_status = 'invoiced' else: order.invoice_status = 'no' @api.depends('order_line.invoice_lines.invoice_id') def _compute_invoice(self): for order in self: invoices = self.env['account.invoice'] for line in order.order_line: invoices |= line.invoice_lines.mapped('invoice_id') order.invoice_ids = invoices order.invoice_count = len(invoices) @api.model def _default_picking_type(self): type_obj = self.env['stock.picking.type'] company_id = self.env.context.get('company_id') or self.env.user.company_id.id branch_id = self.env.context.get( 'branch_id') or self.env.user.default_branch_id.id types = type_obj.search([('code', '=', 'incoming'), ('warehouse_id.company_id', '=', company_id), ('warehouse_id.branch_id', '=', branch_id)]) if not types: types = type_obj.search([('code', '=', 'incoming'), ('warehouse_id', '=', False)]) return types[:1] @api.depends('order_line.move_ids.returned_move_ids', 'order_line.move_ids.state', 'order_line.move_ids.picking_id') def _compute_picking(self): for order in self: pickings = self.env['stock.picking'] for line in order.order_line: # We keep a limited scope on purpose. Ideally, we should also use move_orig_ids and # do some recursive search, but that could be prohibitive if not done correctly. moves = line.move_ids | line.move_ids.mapped('returned_move_ids') pickings |= moves.mapped('picking_id') order.picking_ids = pickings order.picking_count = len(pickings) @api.depends('picking_ids', 'picking_ids.state') def _compute_is_shipped(self): for order in self: if order.picking_ids and all([x.state in ['done', 'cancel'] for x in order.picking_ids]): order.is_shipped = True @api.constrains('picking_type_id', 'branch_id') def _check_branch(self): for order in self: warehouse_branch_id = order.picking_type_id.warehouse_id.branch_id if order.branch_id and warehouse_branch_id != order.branch_id: raise ValidationError( _('Configuration Error of Branch:\n' 'The Purchase Order Branch (%s) and ' 'the Warehouse Branch (%s) of Deliver To must ' 'be the same branch!') % (order.branch_id.name, warehouse_branch_id.name) ) @api.constrains('company_id', 'branch_id') def _check_company(self): for order in self: if order.branch_id and order.company_id != order.branch_id.company_id: raise ValidationError( _('Configuration Error of Company:\n' 'The Purchase Order Company (%s) and ' 'the Company (%s) of Branch must ' 'be the same company!') % (order.company_id.name, order.branch_id.company_id.name) ) READONLY_STATES = { 'purchase': [('readonly', True)], 'done': [('readonly', True)], 'cancel': [('readonly', True)], } name = fields.Char('Order Reference', required=True, index=True, copy=False, default='New') 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 Date', required=True, states=READONLY_STATES, index=True, copy=False, default=fields.Datetime.now,\ help="Depicts the date where the Quotation should be validated and converted into a purchase order.") date_approve = fields.Date('Approval Date', readonly=1, index=True, copy=False) partner_id = fields.Many2one('res.partner', string='Vendor', required=True, states=READONLY_STATES, change_default=True, track_visibility='always') dest_address_id = fields.Many2one('res.partner', 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.user.company_id.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', track_visibility='onchange') 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='# of Bills', copy=False, default=0, store=True) invoice_ids = fields.Many2many('account.invoice', compute="_compute_invoice", string='Bills', copy=False, store=True) invoice_status = fields.Selection([ ('no', 'Nothing to Bill'), ('to invoice', 'Waiting Bills'), ('invoiced', 'No Bill to Receive'), ], string='Billing Status', compute='_get_invoiced', store=True, readonly=True, copy=False, default='no') picking_count = fields.Integer(compute='_compute_picking', string='Receptions', default=0, store=True, compute_sudo=True) picking_ids = fields.Many2many('stock.picking', compute='_compute_picking', string='Receptions', copy=False, store=True, compute_sudo=True) # There is no inverse function on purpose since the date may be different on each line date_planned = fields.Datetime(string='Scheduled Date', compute='_compute_date_planned', store=True, index=True) amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', track_visibility='always') 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', oldname='fiscal_position') payment_term_id = fields.Many2one('account.payment.term', 'Payment Terms') incoterm_id = fields.Many2one('stock.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') create_uid = fields.Many2one('res.users', 'Responsible') company_id = fields.Many2one('res.company', 'Company', required=True, index=True, states=READONLY_STATES, default=lambda self: self.env.user.company_id.id) picking_type_id = fields.Many2one('stock.picking.type', 'Deliver To', states=READONLY_STATES, required=True, default=_default_picking_type,\ help="This will determine operation type of incoming shipment") default_location_dest_id_usage = fields.Selection(related='picking_type_id.default_location_dest_id.usage', string='Destination Location Type',\ help="Technical field used to display the Drop Ship Address", readonly=True) group_id = fields.Many2one('procurement.group', string="Procurement Group", copy=False) is_shipped = fields.Boolean(compute="_compute_is_shipped") website_url = fields.Char( 'Website URL', compute='_website_url', help='The full URL to access the document through the website.') def _website_url(self): for order in self: order.website_url = '/my/purchase/%s' % (order.id) @api.model def name_search(self, name, args=None, operator='ilike', limit=100): args = args or [] domain = [] if name: domain = ['|', ('name', operator, name), ('partner_ref', operator, name)] pos = self.search(domain + args, limit=limit) return pos.name_get() @api.multi @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.model def create(self, vals): if vals.get('name', 'New') == 'New': vals['name'] = self.env['ir.sequence'].next_by_code('purchase.order') or '/' return super(PurchaseOrder, self).create(vals) @api.multi 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() @api.multi def copy(self, default=None): new_po = super(PurchaseOrder, self).copy(default=default) for line in new_po.order_line: 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[:10], uom_id=line.product_uom) line.date_planned = line._get_date_planned(seller) return new_po @api.multi def _track_subtype(self, init_values): self.ensure_one() if 'state' in init_values and self.state == 'purchase': return 'purchase.mt_rfq_approved' elif 'state' in init_values and self.state == 'to approve': return 'purchase.mt_rfq_confirmed' elif 'state' in init_values and self.state == 'done': return 'purchase.mt_rfq_done' return super(PurchaseOrder, self)._track_subtype(init_values) @api.onchange('partner_id', 'company_id') def onchange_partner_id(self): if not self.partner_id: self.fiscal_position_id = False self.payment_term_id = False self.currency_id = False else: self.fiscal_position_id = self.env['account.fiscal.position'].with_context(company_id=self.company_id.id).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.user.company_id.currency_id.id return {} @api.onchange('fiscal_position_id') def _compute_tax_id(self): """ Trigger the recompute of the taxes if the fiscal position is changed on the PO. """ for order in self: order.order_line._compute_tax_id() @api.onchange('partner_id') def onchange_partner_id_warning(self): if not self.partner_id: 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 != '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 {} @api.onchange('picking_type_id') def _onchange_picking_type_id(self): if self.picking_type_id.default_location_dest_id.usage != 'customer': self.dest_address_id = False @api.multi 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', 'default_res_id': self.ids[0], 'default_use_template': bool(template_id), 'default_template_id': template_id, 'default_composition_mode': 'comment', 'custom_layout': "purchase.mail_template_data_notification_email_purchase_order", 'force_email': True }) return { 'name': _('Compose Email'), 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'mail.compose.message', 'views': [(compose_form_id, 'form')], 'view_id': compose_form_id, 'target': 'new', 'context': ctx, } @api.multi def print_quotation(self): self.write({'state': "sent"}) return self.env.ref('purchase.report_purchase_quotation').report_action(self) @api.multi def button_approve(self, force=False): self.write({'state': 'purchase', 'date_approve': fields.Date.context_today(self)}) self._create_picking() self.filtered( lambda p: p.company_id.po_lock == 'lock').write({'state': 'done'}) return {} @api.multi def button_draft(self): self.write({'state': 'draft'}) return {} @api.multi 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.company_id.po_double_validation == 'one_step'\ or (order.company_id.po_double_validation == 'two_step'\ and order.amount_total < self.env.user.company_id.currency_id.compute(order.company_id.po_double_validation_amount, order.currency_id))\ or order.user_has_groups('purchase.group_purchase_manager'): order.button_approve() else: order.write({'state': 'to approve'}) return True @api.multi def button_cancel(self): for order in self: for pick in order.picking_ids: if pick.state == 'done': raise UserError(_('Unable to cancel purchase order %s as some receptions have already been done.') % (order.name)) 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 related vendor bills.")) # If the product is MTO, change the procure_method of the the closest move to purchase to MTS. # The purpose is to link the po that the user will manually generate to the existing moves's chain. if order.state in ('draft', 'sent', 'to approve'): for order_line in order.order_line: if order_line.move_dest_ids: siblings_states = (order_line.move_dest_ids.mapped('move_orig_ids')).mapped('state') if all(state in ('done', 'cancel') for state in siblings_states): order_line.move_dest_ids.write({'procure_method': 'make_to_stock'}) order_line.move_dest_ids._recompute_state() for pick in order.picking_ids.filtered(lambda r: r.state != 'cancel'): pick.action_cancel() order.order_line.write({'move_dest_ids':[(5,0,0)]}) self.write({'state': 'cancel'}) @api.multi def button_unlock(self): self.write({'state': 'purchase'}) @api.multi def button_done(self): self.write({'state': 'done'}) @api.multi def _get_destination_location(self): self.ensure_one() if self.dest_address_id: return self.dest_address_id.property_stock_customer.id return self.picking_type_id.default_location_dest_id.id @api.model def _prepare_picking(self): if not self.group_id: self.group_id = self.group_id.create({ 'name': self.name, 'partner_id': self.partner_id.id }) if not self.partner_id.property_stock_supplier.id: raise UserError(_("You must set a Vendor Location for this partner %s") % self.partner_id.name) return { 'picking_type_id': self.picking_type_id.id, 'partner_id': self.partner_id.id, 'date': self.date_order, 'origin': self.name, 'location_dest_id': self._get_destination_location(), 'location_id': self.partner_id.property_stock_supplier.id, 'company_id': self.company_id.id, } @api.multi def _create_picking(self): StockPicking = self.env['stock.picking'] for order in self: if any([ptype in ['product', 'consu'] for ptype in order.order_line.mapped('product_id.type')]): pickings = order.picking_ids.filtered(lambda x: x.state not in ('done','cancel')) if not pickings: res = order._prepare_picking() picking = StockPicking.create(res) else: picking = pickings[0] moves = order.order_line._create_stock_moves(picking) moves = moves.filtered(lambda x: x.state not in ('done', 'cancel'))._action_confirm() seq = 0 for move in sorted(moves, key=lambda move: move.date_expected): seq += 5 move.sequence = seq moves._action_assign() picking.message_post_with_view('mail.message_origin_link', values={'self': picking, 'origin': order}, subtype_id=self.env.ref('mail.mt_note').id) return True @api.multi 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 partner not in line.product_id.seller_ids.mapped('name') and len(line.product_id.seller_ids) <= 10: currency = partner.property_purchase_currency_id or self.env.user.company_id.currency_id supplierinfo = { 'name': partner.id, 'sequence': max(line.product_id.seller_ids.mapped('sequence')) + 1 if line.product_id.seller_ids else 1, 'product_uom': line.product_uom.id, 'min_qty': 0.0, 'price': self.currency_id.compute(line.price_unit, currency), 'currency_id': currency.id, 'delay': 0, } vals = { 'seller_ids': [(0, 0, supplierinfo)], } try: line.product_id.write(vals) except AccessError: # no write access rights -> just ignore break @api.multi def action_view_picking(self): ''' This function returns an action that display existing picking orders of given purchase order ids. When only one found, show the picking immediately. ''' action = self.env.ref('stock.action_picking_tree') result = action.read()[0] #override the context to get rid of the default filtering on operation type result['context'] = {} pick_ids = self.mapped('picking_ids') #choose the view_mode accordingly if not pick_ids or len(pick_ids) > 1: result['domain'] = "[('id','in',%s)]" % (pick_ids.ids) elif len(pick_ids) == 1: res = self.env.ref('stock.view_picking_form', False) result['views'] = [(res and res.id or False, 'form')] result['res_id'] = pick_ids.id return result @api.multi def action_view_invoice(self): ''' This function returns an action that display existing vendor bills of given purchase order ids. When only one found, show the vendor bill immediately. ''' action = self.env.ref('account.action_invoice_tree2') result = action.read()[0] #override the context to get rid of the default filtering result['context'] = { 'type': 'in_invoice', 'default_purchase_id': self.id, 'default_branch_id': self.branch_id.id } if not self.invoice_ids: # Choose a default account journal in the same currency in case a new invoice is created journal_domain = [ ('type', '=', 'purchase'), ('company_id', '=', self.company_id.id), ('currency_id', '=', self.currency_id.id), ] default_journal_id = self.env['account.journal'].search(journal_domain, limit=1) if default_journal_id: result['context']['default_journal_id'] = default_journal_id.id else: # Use the same account journal than a previous invoice result['context']['default_journal_id'] = self.invoice_ids[0].journal_id.id #choose the view_mode accordingly if len(self.invoice_ids) != 1: result['domain'] = "[('id', 'in', " + str(self.invoice_ids.ids) + ")]" elif len(self.invoice_ids) == 1: res = self.env.ref('account.invoice_supplier_form', False) result['views'] = [(res and res.id or False, 'form')] result['res_id'] = self.invoice_ids.id return result @api.multi def action_set_date_planned(self): for order in self: order.order_line.update({'date_planned': order.date_planned}) class PurchaseOrderLine(models.Model): _name = 'purchase.order.line' _description = 'Purchase Order Line' _order = 'order_id, sequence, id' @api.depends('product_qty', 'price_unit', 'taxes_id') def _compute_amount(self): for line in self: taxes = line.taxes_id.compute_all(line.price_unit, line.order_id.currency_id, line.product_qty, product=line.product_id, partner=line.order_id.partner_id) 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'], }) @api.multi def _compute_tax_id(self): for line in self: fpos = line.order_id.fiscal_position_id or line.order_id.partner_id.property_account_position_id # If company_id is set, always filter taxes by the company taxes = line.product_id.supplier_taxes_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id) line.taxes_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_id) if fpos else taxes @api.depends('invoice_lines.invoice_id.state', 'invoice_lines.quantity') def _compute_qty_invoiced(self): for line in self: qty = 0.0 for inv_line in line.invoice_lines: if inv_line.invoice_id.state not in ['cancel']: if inv_line.invoice_id.type == 'in_invoice': qty += inv_line.uom_id._compute_quantity(inv_line.quantity, line.product_uom) elif inv_line.invoice_id.type == 'in_refund': qty -= inv_line.uom_id._compute_quantity(inv_line.quantity, line.product_uom) line.qty_invoiced = qty @api.depends('order_id.state', 'move_ids.state', 'move_ids.product_uom_qty') def _compute_qty_received(self): for line in self: if line.order_id.state not in ['purchase', 'done']: line.qty_received = 0.0 continue if line.product_id.type not in ['consu', 'product']: line.qty_received = line.product_qty continue total = 0.0 for move in line.move_ids: if move.state == 'done': if move.location_dest_id.usage == "supplier": if move.to_refund: total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) else: total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) line.qty_received = total @api.model def create(self, values): line = super(PurchaseOrderLine, self).create(values) if line.order_id.state == 'purchase': line._create_or_update_picking() msg = _("Extra line with %s ") % (line.product_id.display_name,) line.order_id.message_post(body=msg) return line @api.multi def write(self, values): 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) result = super(PurchaseOrderLine, self).write(values) # Update expected date of corresponding moves if 'date_planned' in values: self.env['stock.move'].search([ ('purchase_line_id', 'in', self.ids), ('state', '!=', 'done') ]).write({'date_expected': values['date_planned']}) if 'product_qty' in values: self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking() return result name = fields.Text(string='Description', required=True) sequence = fields.Integer(string='Sequence', default=10) product_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True) date_planned = fields.Datetime(string='Scheduled Date', required=True, index=True) taxes_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) product_uom = fields.Many2one('product.uom', string='Product Unit of Measure', required=True) product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True, required=True) product_image = fields.Binary( 'Product Image', related="product_id.image", help="Non-stored related field to allow portal user to see the image of the product he has ordered") move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, ondelete='set null', copy=False) price_unit = fields.Float(string='Unit Price', required=True, digits=dp.get_precision('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', string='Analytic Account') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') 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) invoice_lines = fields.One2many('account.invoice.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=dp.get_precision('Product Unit of Measure'), store=True) qty_received = fields.Float(compute='_compute_qty_received', string="Received Qty", digits=dp.get_precision('Product Unit of Measure'), store=True, compute_sudo=True) 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) orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint') move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves') branch_id = fields.Many2one( related='order_id.branch_id', string='Branch', store=True, readonly=True ) @api.multi def _create_or_update_picking(self): for line in self: if line.product_id.type in ('product', 'consu'): # Prevent decreasing below received quantity if float_compare(line.product_qty, line.qty_received, line.product_uom.rounding) < 0: raise UserError('You cannot decrease the ordered quantity below the received quantity.\n' 'Create a return first.') if float_compare(line.product_qty, line.qty_invoiced, line.product_uom.rounding) == -1: # If the quantity is now below the invoiced quantity, create an activity on the vendor bill # inviting the user to create a refund. activity = self.env['mail.activity'].sudo().create({ 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, 'note': _('The quantities on your purchase order indicate less than billed. You should ask for a refund. '), 'res_id': line.invoice_lines[0].invoice_id.id, 'res_model_id': self.env.ref('account.model_account_invoice').id, }) activity._onchange_activity_type_id() # If the user increased quantity of existing line or created a new line pickings = line.order_id.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel') and x.location_dest_id.usage in ('internal', 'transit')) picking = pickings and pickings[0] or False if not picking: res = line.order_id._prepare_picking() picking = self.env['stock.picking'].create(res) move_vals = line._prepare_stock_moves(picking) for move_val in move_vals: self.env['stock.move']\ .create(move_val)\ ._action_confirm()\ ._action_assign() @api.multi def _get_stock_move_price_unit(self): self.ensure_one() line = self[0] order = line.order_id price_unit = line.price_unit if line.taxes_id: price_unit = line.taxes_id.with_context(round=False).compute_all( price_unit, currency=line.order_id.currency_id, quantity=1.0, product=line.product_id, partner=line.order_id.partner_id )['total_excluded'] if line.product_uom.id != line.product_id.uom_id.id: price_unit *= line.product_uom.factor / line.product_id.uom_id.factor if order.currency_id != order.company_id.currency_id: price_unit = order.currency_id.compute(price_unit, order.company_id.currency_id, round=False) return price_unit @api.multi def _prepare_stock_moves(self, picking): """ Prepare the stock moves data for one order line. This function returns a list of dictionary ready to be used in stock.move's create() """ self.ensure_one() res = [] if self.product_id.type not in ['product', 'consu']: return res qty = 0.0 price_unit = self._get_stock_move_price_unit() for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"): qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') template = { 'name': self.name or '', 'product_id': self.product_id.id, 'product_uom': self.product_uom.id, 'date': self.order_id.date_order, 'date_expected': self.date_planned, 'location_id': self.order_id.partner_id.property_stock_supplier.id, 'location_dest_id': self.order_id._get_destination_location(), 'picking_id': picking.id, 'partner_id': self.order_id.dest_address_id.id, 'move_dest_ids': [(4, x) for x in self.move_dest_ids.ids], 'state': 'draft', 'purchase_line_id': self.id, 'company_id': self.order_id.company_id.id, 'price_unit': price_unit, 'picking_type_id': self.order_id.picking_type_id.id, 'group_id': self.order_id.group_id.id, 'origin': self.order_id.name, 'route_ids': self.order_id.picking_type_id.warehouse_id and [(6, 0, [x.id for x in self.order_id.picking_type_id.warehouse_id.route_ids])] or [], 'warehouse_id': self.order_id.picking_type_id.warehouse_id.id, } diff_quantity = self.product_qty - qty if float_compare(diff_quantity, 0.0, precision_rounding=self.product_uom.rounding) > 0: quant_uom = self.product_id.uom_id get_param = self.env['ir.config_parameter'].sudo().get_param if self.product_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1': product_qty = self.product_uom._compute_quantity(diff_quantity, quant_uom, rounding_method='HALF-UP') template['product_uom'] = quant_uom.id template['product_uom_qty'] = product_qty else: template['product_uom_qty'] = diff_quantity res.append(template) return res @api.multi def _create_stock_moves(self, picking): moves = self.env['stock.move'] done = self.env['stock.move'].browse() for line in self: for val in line._prepare_stock_moves(picking): done += moves.create(val) return done @api.multi 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: return datetime.strptime(date_order, DEFAULT_SERVER_DATETIME_FORMAT) + relativedelta(days=seller.delay if seller else 0) else: return datetime.today() + relativedelta(days=seller.delay if seller else 0) def _merge_in_existing_line(self, product_id, product_qty, product_uom, location_id, name, origin, values): """ This function purpose is to be override with the purpose to forbide _run_buy method to merge a new po line in an existing one. """ return True @api.onchange('product_id') def onchange_product_id(self): result = {} if not self.product_id: return result # Reset date, price and quantity since _onchange_quantity will provide default values self.date_planned = datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT) self.price_unit = self.product_qty = 0.0 self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id result['domain'] = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]} product_lang = self.product_id.with_context( lang=self.partner_id.lang, partner_id=self.partner_id.id, ) self.name = product_lang.display_name if product_lang.description_purchase: self.name += '\n' + product_lang.description_purchase fpos = self.order_id.fiscal_position_id if self.env.uid == SUPERUSER_ID: company_id = self.env.user.company_id.id self.taxes_id = fpos.map_tax(self.product_id.supplier_taxes_id.filtered(lambda r: r.company_id.id == company_id)) else: self.taxes_id = fpos.map_tax(self.product_id.supplier_taxes_id) self._suggest_quantity() self._onchange_quantity() return result @api.onchange('product_id') def onchange_product_id_warning(self): if not self.product_id: 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 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[:10], uom_id=self.product_uom) if seller or not self.date_planned: self.date_planned = self._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT) if not seller: 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.compute(price_unit, self.order_id.currency_id) 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 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)\ .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 class ProcurementGroup(models.Model): _inherit = 'procurement.group' @api.model def _get_exceptions_domain(self): return super(ProcurementGroup, self)._get_exceptions_domain() + [('created_purchase_line_id', '=', False)] class ProcurementRule(models.Model): _inherit = 'procurement.rule' action = fields.Selection(selection_add=[('buy', 'Buy')]) @api.multi def _run_buy(self, product_id, product_qty, product_uom, location_id, name, origin, values): cache = {} suppliers = product_id.seller_ids\ .filtered(lambda r: (not r.company_id or r.company_id == values['company_id']) and (not r.product_id or r.product_id == product_id)) if not suppliers: msg = _('There is no vendor associated to the product %s. Please define a vendor for this product.') % (product_id.display_name,) raise UserError(msg) supplier = self._make_po_select_supplier(values, suppliers) partner = supplier.name domain = self._make_po_get_domain(values, partner) if domain in cache: po = cache[domain] else: po = self.env['purchase.order'].sudo().search([dom for dom in domain]) po = po[0] if po else False cache[domain] = po if not po: vals = self._prepare_purchase_order(product_id, product_qty, product_uom, origin, values, partner) company_id = values.get('company_id') and values['company_id'].id or self.env.user.company_id.id po = self.env['purchase.order'].with_context(force_company=company_id).sudo().create(vals) cache[domain] = po elif not po.origin or origin not in po.origin.split(', '): if po.origin: if origin: po.write({'origin': po.origin + ', ' + origin}) else: po.write({'origin': po.origin}) else: po.write({'origin': origin}) # Create Line po_line = False for line in po.order_line: if line.product_id == product_id and line.product_uom == product_id.uom_po_id: if line._merge_in_existing_line(product_id, product_qty, product_uom, location_id, name, origin, values): vals = self._update_purchase_order_line(product_id, product_qty, product_uom, values, line, partner) po_line = line.write(vals) break if not po_line: vals = self._prepare_purchase_order_line(product_id, product_qty, product_uom, values, po, supplier) self.env['purchase.order.line'].sudo().create(vals) def _get_purchase_schedule_date(self, values): """Return the datetime value to use as Schedule Date (``date_planned``) for the Purchase Order Lines created to satisfy the given procurement. """ procurement_date_planned = fields.Datetime.from_string(values['date_planned']) schedule_date = (procurement_date_planned - relativedelta(days=values['company_id'].po_lead)) return schedule_date def _get_purchase_order_date(self, product_id, product_qty, product_uom, values, partner, schedule_date): """Return the datetime value to use as Order Date (``date_order``) for the Purchase Order created to satisfy the given procurement. """ seller = product_id._select_seller( partner_id=partner, quantity=product_qty, date=fields.Date.to_string(schedule_date), uom_id=product_uom) return schedule_date - relativedelta(days=int(seller.delay)) def _update_purchase_order_line(self, product_id, product_qty, product_uom, values, line, partner): procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) seller = product_id._select_seller( partner_id=partner, quantity=line.product_qty + procurement_uom_po_qty, date=line.order_id.date_order and line.order_id.date_order[:10], uom_id=product_id.uom_po_id) price_unit = self.env['account.tax']._fix_tax_included_price_company(seller.price, line.product_id.supplier_taxes_id, line.taxes_id, values['company_id']) if seller else 0.0 if price_unit and seller and line.order_id.currency_id and seller.currency_id != line.order_id.currency_id: price_unit = seller.currency_id.compute(price_unit, line.order_id.currency_id) return { 'product_qty': line.product_qty + procurement_uom_po_qty, 'price_unit': price_unit, 'move_dest_ids': [(4, x.id) for x in values.get('move_dest_ids', [])] } @api.multi def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, values, po, supplier): procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) seller = product_id._select_seller( partner_id=supplier.name, quantity=procurement_uom_po_qty, date=po.date_order and po.date_order[:10], uom_id=product_id.uom_po_id) taxes = product_id.supplier_taxes_id fpos = po.fiscal_position_id taxes_id = fpos.map_tax(taxes) if fpos else taxes if taxes_id: taxes_id = taxes_id.filtered(lambda x: x.company_id.id == values['company_id'].id) price_unit = self.env['account.tax']._fix_tax_included_price_company(seller.price, product_id.supplier_taxes_id, taxes_id, values['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.compute(price_unit, po.currency_id) product_lang = product_id.with_context({ 'lang': supplier.name.lang, 'partner_id': supplier.name.id, }) name = product_lang.display_name if product_lang.description_purchase: name += '\n' + product_lang.description_purchase date_planned = self.env['purchase.order.line']._get_date_planned(seller, po=po).strftime(DEFAULT_SERVER_DATETIME_FORMAT) return { 'name': name, 'product_qty': procurement_uom_po_qty, 'product_id': product_id.id, 'product_uom': product_id.uom_po_id.id, 'price_unit': price_unit, 'date_planned': date_planned, 'orderpoint_id': values.get('orderpoint_id', False) and values.get('orderpoint_id').id, 'taxes_id': [(6, 0, taxes_id.ids)], 'order_id': po.id, 'move_dest_ids': [(4, x.id) for x in values.get('move_dest_ids', [])], } def _prepare_purchase_order(self, product_id, product_qty, product_uom, origin, values, partner): schedule_date = self._get_purchase_schedule_date(values) purchase_date = self._get_purchase_order_date(product_id, product_qty, product_uom, values, partner, schedule_date) fpos = self.env['account.fiscal.position'].with_context(force_company=values['company_id'].id).get_fiscal_position(partner.id) gpo = self.group_propagation_option group = (gpo == 'fixed' and self.group_id.id) or \ (gpo == 'propagate' and values.get('group_id') and values['group_id'].id) or False return { 'partner_id': partner.id, 'picking_type_id': self.picking_type_id.id, 'company_id': values['company_id'].id, 'currency_id': partner.with_context(force_company=values['company_id'].id).property_purchase_currency_id.id or self.env.user.company_id.currency_id.id, 'dest_address_id': values.get('partner_dest_id', False) and values['partner_dest_id'].id, 'origin': origin, 'payment_term_id': partner.with_context(force_company=values['company_id'].id).property_supplier_payment_term_id.id, 'date_order': purchase_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT), 'fiscal_position_id': fpos, 'group_id': group } def _make_po_select_supplier(self, values, suppliers): """ Method intended to be overridden by customized modules to implement any logic in the selection of supplier. """ return suppliers[0] def _make_po_get_domain(self, values, partner): domain = super(ProcurementRule, self)._make_po_get_domain(values, partner) gpo = self.group_propagation_option group = (gpo == 'fixed' and self.group_id) or \ (gpo == 'propagate' and 'group_id' in values and values['group_id']) or False domain += ( ('partner_id', '=', partner.id), ('state', '=', 'draft'), ('picking_type_id', '=', self.picking_type_id.id), ('company_id', '=', values['company_id'].id), ) if group: domain += (('group_id', '=', group.id),) return domain class ProductTemplate(models.Model): _name = 'product.template' _inherit = 'product.template' @api.model def _get_buy_route(self): buy_route = self.env.ref('purchase.route_warehouse0_buy', raise_if_not_found=False) if buy_route: return buy_route.ids return [] @api.multi def _purchase_count(self): for template in self: template.purchase_count = sum([p.purchase_count for p in template.product_variant_ids]) return True property_account_creditor_price_difference = 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 cost price.") purchase_count = fields.Integer(compute='_purchase_count', string='# Purchases') 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 quantity.", default="receive") route_ids = fields.Many2many(default=lambda self: self._get_buy_route()) purchase_line_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Line', help=WARNING_HELP, required=True, default="no-message") purchase_line_warn_msg = fields.Text('Message for Purchase Order Line') class ProductProduct(models.Model): _name = 'product.product' _inherit = 'product.product' @api.multi def _purchase_count(self): domain = [ ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.mapped('id')), ] PurchaseOrderLines = self.env['purchase.order.line'].search(domain) for product in self: product.purchase_count = len(PurchaseOrderLines.filtered(lambda r: r.product_id == product).mapped('order_id')) purchase_count = fields.Integer(compute='_purchase_count', string='# Purchases') 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 MailComposeMessage(models.TransientModel): _inherit = 'mail.compose.message' @api.multi def mail_purchase_order_on_send(self): if not self.filtered('subtype_id.internal'): order = self.env['purchase.order'].browse(self._context['default_res_id']) if order.state == 'draft': order.state = 'sent' @api.multi def send_mail(self, auto_commit=False): if self._context.get('default_model') == 'purchase.order' and self._context.get('default_res_id'): self = self.with_context(mail_post_autofollow=True) self.mail_purchase_order_on_send() return super(MailComposeMessage, self).send_mail(auto_commit=auto_commit)