There were inconsistencies in the calls to `_render`. * the view context could contain information that misled developers. Indeed, the context and value of the view are not supposed to be found in the rendering. Thus by calling `ir.qweb` with the name of the template, we ensure that there is no unwanted information and in addition the cache key is that of the name of the template which saves a query. * the context used for rendering was modified by a method on `ir.ui.view`, except this is not information used by this model. There is now a `_prepare_environment` method residing on `ir.qweb`. This method allows to modify the value dictionary as well as the context in which the rendering will be done. This preparation of the data as well as my security check is done only once per rendering. This also saves some queries * Freeze options for rendering were inconsistent. It could be that options on which rendering depends were not part of the cache key. Thus, depending on the user who generated the generation of the rendering function, there was or was not information in the template. For example for automatic branding. This is no longer possible, because it is the context that is used. The options serving as a cache key are only recorded for information (for the profiling system for example). A simplification of the `ir.qweb.field` models could be made. The report rendering and call `ir.qweb` instead of `ir.ui.view`. Part-of: odoo/odoo#85110
605 lines
32 KiB
Python
605 lines
32 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||
from markupsafe import Markup
|
||
from dateutil.relativedelta import relativedelta
|
||
|
||
from odoo import api, fields, models, SUPERUSER_ID, _
|
||
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
|
||
from odoo.exceptions import UserError
|
||
|
||
from odoo.addons.purchase.models.purchase import PurchaseOrder as Purchase
|
||
|
||
|
||
class PurchaseOrder(models.Model):
|
||
_inherit = 'purchase.order'
|
||
|
||
@api.model
|
||
def _default_picking_type(self):
|
||
return self._get_picking_type(self.env.context.get('company_id') or self.env.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.")
|
||
|
||
incoming_picking_count = fields.Integer("Incoming Shipment count", compute='_compute_incoming_picking_count')
|
||
picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string='Receptions', copy=False, store=True)
|
||
|
||
picking_type_id = fields.Many2one('stock.picking.type', 'Deliver To', states=Purchase.READONLY_STATES, required=True, default=_default_picking_type, domain="['|', ('warehouse_id', '=', False), ('warehouse_id.company_id', '=', company_id)]",
|
||
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")
|
||
effective_date = fields.Datetime("Effective Date", compute='_compute_effective_date', store=True, copy=False,
|
||
help="Completion date of the first receipt order.")
|
||
on_time_rate = fields.Float(related='partner_id.on_time_rate', compute_sudo=False)
|
||
|
||
@api.depends('order_line.move_ids.picking_id')
|
||
def _compute_picking_ids(self):
|
||
for order in self:
|
||
order.picking_ids = order.order_line.move_ids.picking_id
|
||
|
||
@api.depends('picking_ids')
|
||
def _compute_incoming_picking_count(self):
|
||
for order in self:
|
||
order.incoming_picking_count = len(order.picking_ids)
|
||
|
||
@api.depends('picking_ids.date_done')
|
||
def _compute_effective_date(self):
|
||
for order in self:
|
||
pickings = order.picking_ids.filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'internal' and x.date_done)
|
||
order.effective_date = min(pickings.mapped('date_done'), default=False)
|
||
|
||
@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
|
||
else:
|
||
order.is_shipped = False
|
||
|
||
@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.onchange('company_id')
|
||
def _onchange_company_id(self):
|
||
p_type = self.picking_type_id
|
||
if not(p_type and p_type.code == 'incoming' and (p_type.warehouse_id.company_id == self.company_id or not p_type.warehouse_id)):
|
||
self.picking_type_id = self._get_picking_type(self.company_id.id)
|
||
|
||
# --------------------------------------------------
|
||
# CRUD
|
||
# --------------------------------------------------
|
||
|
||
def write(self, vals):
|
||
if vals.get('order_line') and self.state == 'purchase':
|
||
for order in self:
|
||
pre_order_line_qty = {order_line: order_line.product_qty for order_line in order.mapped('order_line')}
|
||
res = super(PurchaseOrder, self).write(vals)
|
||
if vals.get('order_line') and self.state == 'purchase':
|
||
for order in self:
|
||
to_log = {}
|
||
for order_line in order.order_line:
|
||
if pre_order_line_qty.get(order_line, False) and float_compare(pre_order_line_qty[order_line], order_line.product_qty, precision_rounding=order_line.product_uom.rounding) > 0:
|
||
to_log[order_line] = (order_line.product_qty, pre_order_line_qty[order_line])
|
||
if to_log:
|
||
order._log_decrease_ordered_quantity(to_log)
|
||
return res
|
||
|
||
# --------------------------------------------------
|
||
# Actions
|
||
# --------------------------------------------------
|
||
|
||
def button_approve(self, force=False):
|
||
result = super(PurchaseOrder, self).button_approve(force=force)
|
||
self._create_picking()
|
||
return result
|
||
|
||
def button_cancel(self):
|
||
for order in self:
|
||
for move in order.order_line.mapped('move_ids'):
|
||
if move.state == 'done':
|
||
raise UserError(_('Unable to cancel purchase order %s as some receptions have already been done.') % (order.name))
|
||
# If the product is MTO, change the procure_method of 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', 'purchase'):
|
||
for order_line in order.order_line:
|
||
order_line.move_ids._action_cancel()
|
||
if order_line.move_dest_ids:
|
||
move_dest_ids = order_line.move_dest_ids
|
||
if order_line.propagate_cancel:
|
||
move_dest_ids._action_cancel()
|
||
else:
|
||
move_dest_ids.write({'procure_method': 'make_to_stock'})
|
||
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)]})
|
||
|
||
return super(PurchaseOrder, self).button_cancel()
|
||
|
||
def action_view_picking(self):
|
||
return self._get_action_view_picking(self.picking_ids)
|
||
|
||
def _get_action_view_picking(self, pickings):
|
||
""" This function returns an action that display existing picking orders of given purchase order ids. When only one found, show the picking immediately.
|
||
"""
|
||
self.ensure_one()
|
||
result = self.env["ir.actions.actions"]._for_xml_id('stock.action_picking_tree_all')
|
||
# override the context to get rid of the default filtering on operation type
|
||
result['context'] = {'default_partner_id': self.partner_id.id, 'default_origin': self.name, 'default_picking_type_id': self.picking_type_id.id}
|
||
# choose the view_mode accordingly
|
||
if not pickings or len(pickings) > 1:
|
||
result['domain'] = [('id', 'in', pickings.ids)]
|
||
elif len(pickings) == 1:
|
||
res = self.env.ref('stock.view_picking_form', False)
|
||
form_view = [(res and res.id or False, 'form')]
|
||
result['views'] = form_view + [(state, view) for state, view in result.get('views', []) if view != 'form']
|
||
result['res_id'] = pickings.id
|
||
return result
|
||
|
||
def _prepare_invoice(self):
|
||
invoice_vals = super()._prepare_invoice()
|
||
invoice_vals['invoice_incoterm_id'] = self.incoterm_id.id
|
||
return invoice_vals
|
||
|
||
# --------------------------------------------------
|
||
# Business methods
|
||
# --------------------------------------------------
|
||
|
||
def _log_decrease_ordered_quantity(self, purchase_order_lines_quantities):
|
||
|
||
def _keys_in_groupby(move):
|
||
""" group by picking and the responsible for the product the
|
||
move.
|
||
"""
|
||
return (move.picking_id, move.product_id.responsible_id)
|
||
|
||
def _render_note_exception_quantity_po(order_exceptions):
|
||
order_line_ids = self.env['purchase.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]])
|
||
purchase_order_ids = order_line_ids.mapped('order_id')
|
||
move_ids = self.env['stock.move'].concat(*rendering_context.keys())
|
||
impacted_pickings = move_ids.mapped('picking_id')._get_impacted_pickings(move_ids) - move_ids.mapped('picking_id')
|
||
values = {
|
||
'purchase_order_ids': purchase_order_ids,
|
||
'order_exceptions': order_exceptions.values(),
|
||
'impacted_pickings': impacted_pickings,
|
||
}
|
||
return self.env['ir.qweb']._render('purchase_stock.exception_on_po', values)
|
||
|
||
documents = self.env['stock.picking']._log_activity_get_documents(purchase_order_lines_quantities, 'move_ids', 'DOWN', _keys_in_groupby)
|
||
filtered_documents = {}
|
||
for (parent, responsible), rendering_context in documents.items():
|
||
if parent._name == 'stock.picking':
|
||
if parent.state == 'cancel':
|
||
continue
|
||
filtered_documents[(parent, responsible)] = rendering_context
|
||
self.env['stock.picking']._log_activity(_render_note_exception_quantity_po, filtered_documents)
|
||
|
||
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 _get_picking_type(self, company_id):
|
||
picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id.company_id', '=', company_id)])
|
||
if not picking_type:
|
||
picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id', '=', False)])
|
||
return picking_type[:1]
|
||
|
||
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,
|
||
'user_id': False,
|
||
'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,
|
||
}
|
||
|
||
def _create_picking(self):
|
||
StockPicking = self.env['stock.picking']
|
||
for order in self.filtered(lambda po: po.state in ('purchase', 'done')):
|
||
if any(product.type in ['product', 'consu'] for product in order.order_line.product_id):
|
||
order = order.with_company(order.company_id)
|
||
pickings = order.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel'))
|
||
if not pickings:
|
||
res = order._prepare_picking()
|
||
picking = StockPicking.with_user(SUPERUSER_ID).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):
|
||
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
|
||
|
||
def _add_picking_info(self, activity):
|
||
"""Helper method to add picking info to the Date Updated activity when
|
||
vender updates date_planned of the po lines.
|
||
"""
|
||
validated_picking = self.picking_ids.filtered(lambda p: p.state == 'done')
|
||
if validated_picking:
|
||
message = _("Those dates couldn’t be modified accordingly on the receipt %s which had already been validated.", validated_picking[0].name)
|
||
elif not self.picking_ids:
|
||
message = _("Corresponding receipt not found.")
|
||
else:
|
||
message = _("Those dates have been updated accordingly on the receipt %s.", self.picking_ids[0].name)
|
||
activity.note += Markup('<p>{}</p>').format(message)
|
||
|
||
def _create_update_date_activity(self, updated_dates):
|
||
activity = super()._create_update_date_activity(updated_dates)
|
||
self._add_picking_info(activity)
|
||
|
||
def _update_update_date_activity(self, updated_dates, activity):
|
||
# remove old picking info to update it
|
||
note_lines = activity.note.split('<p>')
|
||
note_lines.pop()
|
||
activity.note = Markup('<p>').join(note_lines)
|
||
super()._update_update_date_activity(updated_dates, activity)
|
||
self._add_picking_info(activity)
|
||
|
||
@api.model
|
||
def _get_orders_to_remind(self):
|
||
"""When auto sending reminder mails, don't send for purchase order with
|
||
validated receipts."""
|
||
return super()._get_orders_to_remind().filtered(lambda p: not p.effective_date)
|
||
|
||
|
||
class PurchaseOrderLine(models.Model):
|
||
_inherit = 'purchase.order.line'
|
||
|
||
qty_received_method = fields.Selection(selection_add=[('stock_moves', 'Stock Moves')])
|
||
|
||
move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, copy=False)
|
||
orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint', copy=False, index='btree_not_null')
|
||
move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves')
|
||
product_description_variants = fields.Char('Custom Description')
|
||
propagate_cancel = fields.Boolean('Propagate cancellation', default=True)
|
||
forecasted_issue = fields.Boolean(compute='_compute_forecasted_issue')
|
||
|
||
def _compute_qty_received_method(self):
|
||
super(PurchaseOrderLine, self)._compute_qty_received_method()
|
||
for line in self.filtered(lambda l: not l.display_type):
|
||
if line.product_id.type in ['consu', 'product']:
|
||
line.qty_received_method = 'stock_moves'
|
||
|
||
def _get_po_line_moves(self):
|
||
self.ensure_one()
|
||
moves = self.move_ids.filtered(lambda m: m.product_id == self.product_id)
|
||
if self._context.get('accrual_entry_date'):
|
||
moves = moves.filtered(lambda r: fields.Date.to_date(r.date) <= self._context['accrual_entry_date'])
|
||
return moves
|
||
|
||
@api.depends('move_ids.state', 'move_ids.product_uom_qty', 'move_ids.product_uom')
|
||
def _compute_qty_received(self):
|
||
from_stock_lines = self.filtered(lambda order_line: order_line.qty_received_method == 'stock_moves')
|
||
super(PurchaseOrderLine, self - from_stock_lines)._compute_qty_received()
|
||
for line in self:
|
||
if line.qty_received_method == 'stock_moves':
|
||
total = 0.0
|
||
# In case of a BOM in kit, the products delivered do not correspond to the products in
|
||
# the PO. Therefore, we can skip them since they will be handled later on.
|
||
for move in line._get_po_line_moves():
|
||
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, rounding_method='HALF-UP')
|
||
elif move.origin_returned_move_id and move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned():
|
||
# Edge case: the dropship is returned to the stock, no to the supplier.
|
||
# In this case, the received quantity on the PO is set although we didn't
|
||
# receive the product physically in our stock. To avoid counting the
|
||
# quantity twice, we do nothing.
|
||
pass
|
||
elif (
|
||
move.location_dest_id.usage == "internal"
|
||
and move.to_refund
|
||
and move.location_dest_id
|
||
not in self.env["stock.location"].search(
|
||
[("id", "child_of", move.warehouse_id.view_location_id.id)]
|
||
)
|
||
):
|
||
total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
|
||
else:
|
||
total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
|
||
line._track_qty_received(total)
|
||
line.qty_received = total
|
||
|
||
@api.depends('product_uom_qty', 'date_planned')
|
||
def _compute_forecasted_issue(self):
|
||
for line in self:
|
||
warehouse = line.order_id.picking_type_id.warehouse_id
|
||
line.forecasted_issue = False
|
||
if line.product_id:
|
||
virtual_available = line.product_id.with_context(warehouse=warehouse.id, to_date=line.date_planned).virtual_available
|
||
if line.state == 'draft':
|
||
virtual_available += line.product_uom_qty
|
||
if virtual_available < 0:
|
||
line.forecasted_issue = True
|
||
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
lines = super(PurchaseOrderLine, self).create(vals_list)
|
||
lines.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking()
|
||
return lines
|
||
|
||
def write(self, values):
|
||
if values.get('date_planned'):
|
||
new_date = fields.Datetime.to_datetime(values['date_planned'])
|
||
self.filtered(lambda l: not l.display_type)._update_move_date_deadline(new_date)
|
||
lines = self.filtered(lambda l: l.order_id.state == 'purchase')
|
||
previous_product_qty = {line.id: line.product_uom_qty for line in lines}
|
||
result = super(PurchaseOrderLine, self).write(values)
|
||
if 'product_qty' in values:
|
||
lines.with_context(previous_product_qty=previous_product_qty)._create_or_update_picking()
|
||
return result
|
||
|
||
def action_product_forecast_report(self):
|
||
self.ensure_one()
|
||
action = self.product_id.action_product_forecast_report()
|
||
action['context'] = {
|
||
'active_id': self.product_id.id,
|
||
'active_model': 'product.product',
|
||
'move_to_match_ids': self.move_ids.filtered(lambda m: m.product_id == self.product_id).ids,
|
||
'purchase_line_to_match_id': self.id,
|
||
}
|
||
warehouse = self.order_id.picking_type_id.warehouse_id
|
||
if warehouse:
|
||
action['context']['warehouse'] = warehouse.id
|
||
return action
|
||
|
||
def unlink(self):
|
||
self.move_ids._action_cancel()
|
||
|
||
ppg_cancel_lines = self.filtered(lambda line: line.propagate_cancel)
|
||
ppg_cancel_lines.move_dest_ids._action_cancel()
|
||
|
||
not_ppg_cancel_lines = self.filtered(lambda line: not line.propagate_cancel)
|
||
not_ppg_cancel_lines.move_dest_ids.write({'procure_method': 'make_to_stock'})
|
||
not_ppg_cancel_lines.move_dest_ids._recompute_state()
|
||
|
||
return super().unlink()
|
||
|
||
# --------------------------------------------------
|
||
# Business methods
|
||
# --------------------------------------------------
|
||
|
||
def _update_move_date_deadline(self, new_date):
|
||
""" Updates corresponding move picking line deadline dates that are not yet completed. """
|
||
moves_to_update = self.move_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
|
||
if not moves_to_update:
|
||
moves_to_update = self.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel'))
|
||
for move in moves_to_update:
|
||
move.date_deadline = new_date
|
||
|
||
def _create_or_update_picking(self):
|
||
for line in self:
|
||
if line.product_id and 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.
|
||
line.invoice_lines[0].move_id.activity_schedule(
|
||
'mail.mail_activity_data_warning',
|
||
note=_('The quantities on your purchase order indicate less than billed. You should ask for a refund.'))
|
||
|
||
# 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', 'customer'))
|
||
picking = pickings and pickings[0] or False
|
||
if not picking:
|
||
res = line.order_id._prepare_picking()
|
||
picking = self.env['stock.picking'].create(res)
|
||
|
||
moves = line._create_stock_moves(picking)
|
||
moves._action_confirm()._action_assign()
|
||
|
||
def _get_stock_move_price_unit(self):
|
||
self.ensure_one()
|
||
order = self.order_id
|
||
price_unit = self.price_unit
|
||
price_unit_prec = self.env['decimal.precision'].precision_get('Product Price')
|
||
if self.taxes_id:
|
||
qty = self.product_qty or 1
|
||
price_unit = self.taxes_id.with_context(round=False).compute_all(
|
||
price_unit, currency=self.order_id.currency_id, quantity=qty, product=self.product_id, partner=self.order_id.partner_id
|
||
)['total_void']
|
||
price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec)
|
||
if self.product_uom.id != self.product_id.uom_id.id:
|
||
price_unit *= self.product_uom.factor / self.product_id.uom_id.factor
|
||
if order.currency_id != order.company_id.currency_id:
|
||
price_unit = order.currency_id._convert(
|
||
price_unit, order.company_id.currency_id, self.company_id, self.date_order or fields.Date.today(), round=False)
|
||
return price_unit
|
||
|
||
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
|
||
|
||
price_unit = self._get_stock_move_price_unit()
|
||
qty = self._get_qty_procurement()
|
||
|
||
move_dests = self.move_dest_ids
|
||
if not move_dests:
|
||
move_dests = self.move_ids.move_dest_ids.filtered(lambda m: m.state != 'cancel' and not m.location_dest_id.usage == 'supplier')
|
||
|
||
if not move_dests:
|
||
qty_to_attach = 0
|
||
qty_to_push = self.product_qty - qty
|
||
else:
|
||
move_dests_initial_demand = self.product_id.uom_id._compute_quantity(
|
||
sum(move_dests.filtered(lambda m: m.state != 'cancel' and not m.location_dest_id.usage == 'supplier').mapped('product_qty')),
|
||
self.product_uom, rounding_method='HALF-UP')
|
||
qty_to_attach = move_dests_initial_demand - qty
|
||
qty_to_push = self.product_qty - move_dests_initial_demand
|
||
|
||
if float_compare(qty_to_attach, 0.0, precision_rounding=self.product_uom.rounding) > 0:
|
||
product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_attach, self.product_id.uom_id)
|
||
res.append(self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom))
|
||
if not float_is_zero(qty_to_push, precision_rounding=self.product_uom.rounding):
|
||
product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_push, self.product_id.uom_id)
|
||
extra_move_vals = self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom)
|
||
extra_move_vals['move_dest_ids'] = False # don't attach
|
||
res.append(extra_move_vals)
|
||
return res
|
||
|
||
def _get_qty_procurement(self):
|
||
self.ensure_one()
|
||
qty = 0.0
|
||
outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
|
||
for move in outgoing_moves:
|
||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
|
||
for move in incoming_moves:
|
||
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
|
||
return qty
|
||
|
||
def _check_orderpoint_picking_type(self):
|
||
warehouse_loc = self.order_id.picking_type_id.warehouse_id.view_location_id
|
||
dest_loc = self.move_dest_ids.location_id or self.orderpoint_id.location_id
|
||
if warehouse_loc and dest_loc and dest_loc.warehouse_id and not warehouse_loc.parent_path in dest_loc[0].parent_path:
|
||
raise UserError(_('For the product %s, the warehouse of the operation type (%s) is inconsistent with the location (%s) of the reordering rule (%s). Change the operation type or cancel the request for quotation.',
|
||
self.product_id.display_name, self.order_id.picking_type_id.display_name, self.orderpoint_id.location_id.display_name, self.orderpoint_id.display_name))
|
||
|
||
def _prepare_stock_move_vals(self, picking, price_unit, product_uom_qty, product_uom):
|
||
self.ensure_one()
|
||
self._check_orderpoint_picking_type()
|
||
product = self.product_id.with_context(lang=self.order_id.dest_address_id.lang or self.env.user.lang)
|
||
date_planned = self.date_planned or self.order_id.date_planned
|
||
return {
|
||
# truncate to 2000 to avoid triggering index limit error
|
||
# TODO: remove index in master?
|
||
'name': (self.name or '')[:2000],
|
||
'product_id': self.product_id.id,
|
||
'date': date_planned,
|
||
'date_deadline': date_planned,
|
||
'location_id': self.order_id.partner_id.property_stock_supplier.id,
|
||
'location_dest_id': (self.orderpoint_id and not (self.move_ids | self.move_dest_ids)) and self.orderpoint_id.location_id.id or 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,
|
||
'description_picking': product.description_pickingin or self.name,
|
||
'propagate_cancel': self.propagate_cancel,
|
||
'warehouse_id': self.order_id.picking_type_id.warehouse_id.id,
|
||
'product_uom_qty': product_uom_qty,
|
||
'product_uom': product_uom.id,
|
||
'product_packaging_id': self.product_packaging_id.id,
|
||
}
|
||
|
||
@api.model
|
||
def _prepare_purchase_order_line_from_procurement(self, product_id, product_qty, product_uom, company_id, values, po):
|
||
line_description = ''
|
||
if values.get('product_description_variants'):
|
||
line_description = values['product_description_variants']
|
||
supplier = values.get('supplier')
|
||
res = self._prepare_purchase_order_line(product_id, product_qty, product_uom, company_id, supplier, po)
|
||
# We need to keep the vendor name set in _prepare_purchase_order_line. To avoid redundancy
|
||
# in the line name, we add the line_description only if different from the product name.
|
||
# This way, we shoud not lose any valuable information.
|
||
if line_description and product_id.name != line_description:
|
||
res['name'] += '\n' + line_description
|
||
res['date_planned'] = values.get('date_planned')
|
||
res['move_dest_ids'] = [(4, x.id) for x in values.get('move_dest_ids', [])]
|
||
res['orderpoint_id'] = values.get('orderpoint_id', False) and values.get('orderpoint_id').id
|
||
res['propagate_cancel'] = values.get('propagate_cancel')
|
||
res['product_description_variants'] = values.get('product_description_variants')
|
||
return res
|
||
|
||
def _create_stock_moves(self, picking):
|
||
values = []
|
||
for line in self.filtered(lambda l: not l.display_type):
|
||
for val in line._prepare_stock_moves(picking):
|
||
values.append(val)
|
||
line.move_dest_ids.created_purchase_line_id = False
|
||
|
||
return self.env['stock.move'].create(values)
|
||
|
||
def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
|
||
""" Return the record in self where the procument with values passed as
|
||
args can be merged. If it returns an empty record then a new line will
|
||
be created.
|
||
"""
|
||
description_picking = ''
|
||
if values.get('product_description_variants'):
|
||
description_picking = values['product_description_variants']
|
||
lines = self.filtered(
|
||
lambda l: l.propagate_cancel == values['propagate_cancel']
|
||
and ((values['orderpoint_id'] and not values['move_dest_ids']) and l.orderpoint_id == values['orderpoint_id'] or True)
|
||
)
|
||
|
||
# In case 'product_description_variants' is in the values, we also filter on the PO line
|
||
# name. This way, we can merge lines with the same description. To do so, we need the
|
||
# product name in the context of the PO partner.
|
||
if lines and values.get('product_description_variants'):
|
||
partner = self.mapped('order_id.partner_id')[:1]
|
||
product_lang = product_id.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
|
||
lines = lines.filtered(lambda l: l.name == name + '\n' + description_picking)
|
||
if lines:
|
||
return lines[0]
|
||
|
||
return lines and lines[0] or self.env['purchase.order.line']
|
||
|
||
def _get_outgoing_incoming_moves(self):
|
||
outgoing_moves = self.env['stock.move']
|
||
incoming_moves = self.env['stock.move']
|
||
|
||
for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id):
|
||
if move.location_dest_id.usage == "supplier" and move.to_refund:
|
||
outgoing_moves |= move
|
||
elif move.location_dest_id.usage != "supplier":
|
||
if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
|
||
incoming_moves |= move
|
||
|
||
return outgoing_moves, incoming_moves
|
||
|
||
def _update_date_planned(self, updated_date):
|
||
move_to_update = self.move_ids.filtered(lambda m: m.state not in ['done', 'cancel'])
|
||
if not self.move_ids or move_to_update: # Only change the date if there is no move done or none
|
||
super()._update_date_planned(updated_date)
|
||
if move_to_update:
|
||
self._update_move_date_deadline(updated_date)
|
||
|
||
@api.model
|
||
def _update_qty_received_method(self):
|
||
"""Update qty_received_method for old PO before install this module."""
|
||
self.search([])._compute_qty_received_method()
|