Files
odoo_source/addons/purchase_stock/models/purchase.py
T
Nicolas Galler 131c06703b [FIX] purchase_stock: do not include internal notes in generated PO
Behavior prior to this commit:

When a PO is generated (via reordering rule, mto rule, or manual replenish),
the description on the PO line includes the internal notes.

Behavior after this commit:

The description on the PO line does not include the internal notes.

Note:

Per c324526ad0, the description on the picking also does not include
the internal notes (anymore), and the test has been updated to reflect
that.

opw-24222438

closes odoo/odoo#64883

X-original-commit: 5db67fb2931517ae94fe0396ef80e9ad997e59cd
Signed-off-by: Nicolas Galler <ngaller@users.noreply.github.com>
2021-01-27 11:16:24 +00:00

543 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo.tools.float_utils import float_compare
from datetime import datetime
from dateutil.relativedelta import relativedelta
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.")
picking_count = fields.Integer(compute='_compute_picking', string='Picking count', default=0, store=True)
picking_ids = fields.Many2many('stock.picking', compute='_compute_picking', 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')
@api.depends('order_line.move_ids.picking_id')
def _compute_picking(self):
for order in self:
pickings = order.order_line.move_ids.picking_id
order.picking_ids = pickings
order.picking_count = len(pickings)
@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):
""" This function returns an action that display existing picking orders of given purchase order ids. When only one found, show the picking immediately.
"""
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}
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)
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'] = pick_ids.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_sorted(move):
""" sort by picking and the responsible for the product the
move.
"""
return (move.picking_id.id, move.product_id.responsible_id.id)
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.ref('purchase_stock.exception_on_po')._render(values=values)
documents = self.env['stock.picking']._log_activity_get_documents(purchase_order_lines_quantities, 'move_ids', 'DOWN', _keys_in_sorted, _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:
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:
activity.note += _("<p>Those dates couldn’t be modified accordingly on the receipt %s which had already been validated.</p>") % validated_picking[0].name
elif not self.picking_ids:
activity.note += _("<p>Corresponding receipt not found.</p>")
else:
activity.note += _("<p>Those dates have been updated accordingly on the receipt %s.</p>") % self.picking_ids[0].name
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 = '<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')
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)
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'
@api.depends('move_ids.state', 'move_ids.product_uom_qty', 'move_ids.product_uom')
def _compute_qty_received(self):
super(PurchaseOrderLine, self)._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.move_ids.filtered(lambda m: m.product_id == line.product_id):
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)
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)
else:
total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
line._track_qty_received(total)
line.qty_received = total
@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):
for line in self.filtered(lambda l: not l.display_type):
# PO date_planned overrides any PO line date_planned values
if values.get('date_planned'):
new_date = fields.Datetime.to_datetime(values['date_planned'])
self._update_move_date_deadline(new_date)
result = super(PurchaseOrderLine, self).write(values)
if 'product_qty' in values:
self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking()
return result
# --------------------------------------------------
# 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 + relativedelta(days=move.company_id.po_lead)
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()
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_void']
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._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
qty = 0.0
price_unit = self._get_stock_move_price_unit()
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')
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 float_compare(qty_to_push, 0.0, precision_rounding=self.product_uom.rounding) > 0:
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 _prepare_stock_move_vals(self, picking, price_unit, product_uom_qty, product_uom):
self.ensure_one()
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 + relativedelta(days=self.order_id.company_id.po_lead),
'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,
'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,
'product_uom_qty': product_uom_qty,
'product_uom': product_uom.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['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()