370 lines
18 KiB
Python
370 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.tools.float_utils import float_compare
|
|
|
|
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):
|
|
type_obj = self.env['stock.picking.type']
|
|
company_id = self.env.context.get('company_id') or self.env.user.company_id.id
|
|
types = type_obj.search([('code', '=', 'incoming'), ('warehouse_id.company_id', '=', company_id)])
|
|
if not types:
|
|
types = type_obj.search([('code', '=', 'incoming'), ('warehouse_id', '=', False)])
|
|
return types[:1]
|
|
|
|
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.")
|
|
|
|
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,
|
|
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")
|
|
|
|
@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.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
|
|
|
|
# --------------------------------------------------
|
|
# 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
|
|
# --------------------------------------------------
|
|
|
|
@api.multi
|
|
def button_approve(self, force=False):
|
|
result = super(PurchaseOrder, self).button_approve(force=force)
|
|
self._create_picking()
|
|
return result
|
|
|
|
@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))
|
|
# 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()
|
|
return super(PurchaseOrder, self).button_cancel()
|
|
|
|
@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 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
|
|
|
|
# --------------------------------------------------
|
|
# 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)
|
|
|
|
@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
|
|
|
|
|
|
class PurchaseOrderLine(models.Model):
|
|
_inherit = 'purchase.order.line'
|
|
|
|
move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, ondelete='set null', copy=False)
|
|
orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint')
|
|
move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves')
|
|
|
|
@api.model
|
|
def create(self, values):
|
|
line = super(PurchaseOrderLine, self).create(values)
|
|
if line.order_id.state == 'purchase':
|
|
line._create_or_update_picking()
|
|
return line
|
|
|
|
@api.multi
|
|
def write(self, values):
|
|
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
|
|
|
|
# --------------------------------------------------
|
|
# Business methods
|
|
# --------------------------------------------------
|
|
|
|
@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._convert(
|
|
price_unit, order.company_id.currency_id, self.company_id, self.date_order or fields.Date.today(), 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
|
|
|
|
def _update_received_qty(self):
|
|
for line in self:
|
|
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
|
|
|
|
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
|