1225 lines
68 KiB
Python
1225 lines
68 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
|
||
|
||
from datetime import datetime
|
||
from dateutil import relativedelta
|
||
from itertools import groupby
|
||
from operator import itemgetter
|
||
|
||
from flectra import api, fields, models, _
|
||
from flectra.addons import decimal_precision as dp
|
||
from flectra.exceptions import UserError
|
||
from flectra.tools import DEFAULT_SERVER_DATETIME_FORMAT
|
||
from flectra.tools.float_utils import float_compare, float_round, float_is_zero
|
||
|
||
PROCUREMENT_PRIORITIES = [('0', 'Not urgent'), ('1', 'Normal'), ('2', 'Urgent'), ('3', 'Very Urgent')]
|
||
|
||
|
||
class StockMove(models.Model):
|
||
_name = "stock.move"
|
||
_description = "Stock Move"
|
||
_order = 'picking_id, sequence, id'
|
||
|
||
def _default_group_id(self):
|
||
if self.env.context.get('default_picking_id'):
|
||
return self.env['stock.picking'].browse(self.env.context['default_picking_id']).group_id.id
|
||
return False
|
||
|
||
name = fields.Char('Description', index=True, required=True)
|
||
sequence = fields.Integer('Sequence', default=10)
|
||
priority = fields.Selection(PROCUREMENT_PRIORITIES, 'Priority', default='1')
|
||
create_date = fields.Datetime('Creation Date', index=True, readonly=True)
|
||
date = fields.Datetime(
|
||
'Date', default=fields.Datetime.now, index=True, required=True,
|
||
states={'done': [('readonly', True)]},
|
||
help="Move date: scheduled date until move is done, then date of actual move processing")
|
||
company_id = fields.Many2one(
|
||
'res.company', 'Company',
|
||
default=lambda self: self.env['res.company']._company_default_get('stock.move'),
|
||
index=True, required=True)
|
||
date_expected = fields.Datetime(
|
||
'Expected Date', default=fields.Datetime.now, index=True, required=True,
|
||
states={'done': [('readonly', True)]},
|
||
help="Scheduled date for the processing of this move")
|
||
product_id = fields.Many2one(
|
||
'product.product', 'Product',
|
||
domain=[('type', 'in', ['product', 'consu'])], index=True, required=True,
|
||
states={'done': [('readonly', True)]})
|
||
ordered_qty = fields.Float('Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'))
|
||
product_qty = fields.Float(
|
||
'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty',
|
||
digits=0, store=True,
|
||
help='Quantity in the default UoM of the product')
|
||
product_uom_qty = fields.Float(
|
||
'Initial Demand',
|
||
digits=dp.get_precision('Product Unit of Measure'),
|
||
default=0.0, required=True, states={'done': [('readonly', True)]},
|
||
help="This is the quantity of products from an inventory "
|
||
"point of view. For moves in the state 'done', this is the "
|
||
"quantity of products that were actually moved. For other "
|
||
"moves, this is the quantity of product that is planned to "
|
||
"be moved. Lowering this quantity does not generate a "
|
||
"backorder. Changing this quantity on assigned moves affects "
|
||
"the product reservation, and should be done with care.")
|
||
product_uom = fields.Many2one('product.uom', 'Unit of Measure', required=True)
|
||
# TDE FIXME: make it stored, otherwise group will not work
|
||
product_tmpl_id = fields.Many2one(
|
||
'product.template', 'Product Template',
|
||
related='product_id.product_tmpl_id',
|
||
help="Technical: used in views")
|
||
product_packaging = fields.Many2one(
|
||
'product.packaging', 'Preferred Packaging',
|
||
help="It specifies attributes of packaging like type, quantity of packaging,etc.")
|
||
location_id = fields.Many2one(
|
||
'stock.location', 'Source Location',
|
||
auto_join=True, index=True, required=True,
|
||
help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations.")
|
||
location_dest_id = fields.Many2one(
|
||
'stock.location', 'Destination Location',
|
||
auto_join=True, index=True, required=True,
|
||
help="Location where the system will stock the finished products.")
|
||
partner_id = fields.Many2one(
|
||
'res.partner', 'Destination Address ',
|
||
states={'done': [('readonly', True)]},
|
||
help="Optional address where goods are to be delivered, specifically used for allotment")
|
||
move_dest_ids = fields.Many2many(
|
||
'stock.move', 'stock_move_move_rel', 'move_orig_id', 'move_dest_id', 'Destination Moves',
|
||
copy=False,
|
||
help="Optional: next stock move when chaining them")
|
||
move_orig_ids = fields.Many2many(
|
||
'stock.move', 'stock_move_move_rel', 'move_dest_id', 'move_orig_id', 'Original Move',
|
||
copy=False,
|
||
help="Optional: previous stock move when chaining them")
|
||
picking_id = fields.Many2one('stock.picking', 'Transfer Reference', index=True, states={'done': [('readonly', True)]})
|
||
picking_partner_id = fields.Many2one('res.partner', 'Transfer Destination Address', related='picking_id.partner_id')
|
||
note = fields.Text('Notes')
|
||
state = fields.Selection([
|
||
('draft', 'New'), ('cancel', 'Cancelled'),
|
||
('waiting', 'Waiting Another Move'),
|
||
('confirmed', 'Waiting Availability'),
|
||
('partially_available', 'Partially Available'),
|
||
('assigned', 'Available'),
|
||
('done', 'Done')], string='Status',
|
||
copy=False, default='draft', index=True, readonly=True,
|
||
help="* New: When the stock move is created and not yet confirmed.\n"
|
||
"* Waiting Another Move: This state can be seen when a move is waiting for another one, for example in a chained flow.\n"
|
||
"* Waiting Availability: This state is reached when the procurement resolution is not straight forward. It may need the scheduler to run, a component to be manufactured...\n"
|
||
"* Available: When products are reserved, it is set to \'Available\'.\n"
|
||
"* Done: When the shipment is processed, the state is \'Done\'.")
|
||
price_unit = fields.Float(
|
||
'Unit Price', help="Technical field used to record the product cost set by the user during a picking confirmation (when costing "
|
||
"method used is 'average price' or 'real'). Value given in company currency and in product uom.", copy=False) # as it's a technical field, we intentionally don't provide the digits attribute
|
||
backorder_id = fields.Many2one('stock.picking', 'Back Order of', related='picking_id.backorder_id', index=True)
|
||
origin = fields.Char("Source Document")
|
||
procure_method = fields.Selection([
|
||
('make_to_stock', 'Default: Take From Stock'),
|
||
('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method',
|
||
default='make_to_stock', required=True,
|
||
help="By default, the system will take from the stock in the source location and passively wait for availability. "
|
||
"The other possibility allows you to directly create a procurement on the source location (and thus ignore "
|
||
"its current stock) to gather products. If we want to chain moves and have this one to wait for the previous, "
|
||
"this second option should be chosen.")
|
||
scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True)
|
||
scrap_ids = fields.One2many('stock.scrap', 'move_id')
|
||
group_id = fields.Many2one('procurement.group', 'Procurement Group', default=_default_group_id)
|
||
rule_id = fields.Many2one('procurement.rule', 'Procurement Rule', ondelete='restrict', help='The procurement rule that created this stock move')
|
||
push_rule_id = fields.Many2one('stock.location.path', 'Push Rule', ondelete='restrict', help='The push rule that created this stock move')
|
||
propagate = fields.Boolean(
|
||
'Propagate cancel and split', default=True,
|
||
help='If checked, when this move is cancelled, cancel the linked move too')
|
||
picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type')
|
||
inventory_id = fields.Many2one('stock.inventory', 'Inventory')
|
||
move_line_ids = fields.One2many('stock.move.line', 'move_id')
|
||
move_line_nosuggest_ids = fields.One2many('stock.move.line', 'move_id', domain=[('product_qty', '=', 0.0)])
|
||
origin_returned_move_id = fields.Many2one('stock.move', 'Origin return move', copy=False, help='Move that created the return move')
|
||
returned_move_ids = fields.One2many('stock.move', 'origin_returned_move_id', 'All returned moves', help='Optional: all returned moves created from this move')
|
||
reserved_availability = fields.Float(
|
||
'Quantity Reserved', compute='_compute_reserved_availability',
|
||
digits=dp.get_precision('Product Unit of Measure'),
|
||
readonly=True, help='Quantity that has already been reserved for this move')
|
||
availability = fields.Float(
|
||
'Forecasted Quantity', compute='_compute_product_availability',
|
||
readonly=True, help='Quantity in stock that can still be reserved for this move')
|
||
string_availability_info = fields.Text(
|
||
'Availability', compute='_compute_string_qty_information',
|
||
readonly=True, help='Show various information on stock availability for this move')
|
||
restrict_partner_id = fields.Many2one('res.partner', 'Owner ', help="Technical field used to depict a restriction on the ownership of quants to consider when marking this move as 'done'")
|
||
route_ids = fields.Many2many('stock.location.route', 'stock_location_route_move', 'move_id', 'route_id', 'Destination route', help="Preferred route")
|
||
warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', help="Technical field depicting the warehouse to consider for the route selection on the next procurement (if any).")
|
||
has_tracking = fields.Selection(related='product_id.tracking', string='Product with Tracking')
|
||
quantity_done = fields.Float('Quantity Done', compute='_quantity_done_compute', digits=dp.get_precision('Product Unit of Measure'), inverse='_quantity_done_set')
|
||
show_operations = fields.Boolean(related='picking_id.picking_type_id.show_operations')
|
||
show_details_visible = fields.Boolean('Details Visible', compute='_compute_show_details_visible')
|
||
show_reserved_availability = fields.Boolean('From Supplier', compute='_compute_show_reserved_availability')
|
||
picking_code = fields.Selection(related='picking_id.picking_type_id.code', readonly=True)
|
||
product_type = fields.Selection(related='product_id.type', readonly=True)
|
||
additional = fields.Boolean("Whether the move was added after the picking's confirmation", default=False)
|
||
is_locked = fields.Boolean(compute='_compute_is_locked', readonly=True)
|
||
is_initial_demand_editable = fields.Boolean('Is initial demand editable', compute='_compute_is_initial_demand_editable')
|
||
is_quantity_done_editable = fields.Boolean('Is quantity done editable', compute='_compute_is_quantity_done_editable')
|
||
reference = fields.Char(compute='_compute_reference', string="Reference", store=True)
|
||
has_move_lines = fields.Boolean(compute='_compute_has_move_lines')
|
||
out_qty = fields.Float("Out Qty")
|
||
bal_qty = fields.Float("Balance")
|
||
move_type = fields.Selection([('in', 'In'), ('out', 'Out')], "Move type")
|
||
branch_id = fields.Many2one(
|
||
related='location_id.branch_id', store=True,
|
||
string='Source Location Branch',
|
||
)
|
||
branch_dest_id = fields.Many2one(
|
||
related='location_dest_id.branch_id', store=True,
|
||
string='Dest. Location Branch',
|
||
)
|
||
|
||
@api.multi
|
||
@api.constrains('branch_id', 'location_id', 'picking_id',
|
||
'branch_dest_id', 'location_dest_id')
|
||
def _check_stock_move_branch(self):
|
||
for record in self:
|
||
if not record.branch_id:
|
||
return True
|
||
if (record.location_id and
|
||
record.location_id.branch_id and
|
||
record.picking_id and record.branch_id != record.picking_id.branch_id
|
||
) and (
|
||
record.location_dest_id and
|
||
record.location_dest_id.branch_id and
|
||
record.picking_id and record.branch_dest_id != record.picking_id.branch_id
|
||
):
|
||
raise UserError(
|
||
_('Configuration error of Branch:\nThe Stock moves must '
|
||
'be related to a source or destination location '
|
||
'that belongs to the requesting Branch.')
|
||
)
|
||
|
||
@api.depends('picking_id.is_locked')
|
||
def _compute_is_locked(self):
|
||
for move in self:
|
||
if move.picking_id:
|
||
move.is_locked = move.picking_id.is_locked
|
||
|
||
|
||
@api.depends('product_id', 'has_tracking', 'move_line_ids', 'location_id', 'location_dest_id')
|
||
def _compute_show_details_visible(self):
|
||
""" According to this field, the button that calls `action_show_details` will be displayed
|
||
to work on a move from its picking form view, or not.
|
||
"""
|
||
for move in self:
|
||
if not move.product_id:
|
||
move.show_details_visible = False
|
||
continue
|
||
|
||
multi_locations_enabled = False
|
||
if self.user_has_groups('stock.group_stock_multi_locations'):
|
||
multi_locations_enabled = move.location_id.child_ids or move.location_dest_id.child_ids
|
||
has_package = move.move_line_ids.mapped('package_id') | move.move_line_ids.mapped('result_package_id')
|
||
consignment_enabled = self.user_has_groups('stock.group_tracking_owner')
|
||
if move.picking_id.picking_type_id.show_operations is False\
|
||
and (move.state != 'draft' or (not self._context.get('planned_picking') and move.state == 'draft'))\
|
||
and (multi_locations_enabled or move.has_tracking != 'none' or len(move.move_line_ids) > 1 or has_package or consignment_enabled):
|
||
move.show_details_visible = True
|
||
else:
|
||
move.show_details_visible = False
|
||
|
||
def _compute_show_reserved_availability(self):
|
||
""" This field is only of use in an attrs in the picking view, in order to hide the
|
||
"available" column if the move is coming from a supplier.
|
||
"""
|
||
for move in self:
|
||
move.show_reserved_availability = not move.location_id.usage == 'supplier'
|
||
|
||
@api.depends('state', 'picking_id')
|
||
def _compute_is_initial_demand_editable(self):
|
||
for move in self:
|
||
if self._context.get('planned_picking'):
|
||
move.is_initial_demand_editable = True
|
||
elif not move.picking_id.is_locked and move.state != 'done' and move.picking_id:
|
||
move.is_initial_demand_editable = True
|
||
else:
|
||
move.is_initial_demand_editable = False
|
||
|
||
@api.multi
|
||
@api.depends('state', 'picking_id', 'product_id')
|
||
def _compute_is_quantity_done_editable(self):
|
||
for move in self:
|
||
if not move.product_id:
|
||
move.is_quantity_done_editable = False
|
||
elif self._context.get('planned_picking') and move.picking_id.state == 'draft':
|
||
move.is_quantity_done_editable = False
|
||
elif move.picking_id.is_locked and move.state in ('done', 'cancel'):
|
||
move.is_quantity_done_editable = False
|
||
elif move.show_details_visible:
|
||
move.is_quantity_done_editable = False
|
||
elif move.show_operations:
|
||
move.is_quantity_done_editable = False
|
||
else:
|
||
move.is_quantity_done_editable = True
|
||
|
||
@api.depends('picking_id', 'name')
|
||
def _compute_reference(self):
|
||
for move in self:
|
||
move.reference = move.picking_id.name if move.picking_id else move.name
|
||
|
||
@api.depends('move_line_ids')
|
||
def _compute_has_move_lines(self):
|
||
for move in self:
|
||
move.has_move_lines = bool(move.move_line_ids)
|
||
|
||
@api.one
|
||
@api.depends('product_id', 'product_uom', 'product_uom_qty')
|
||
def _compute_product_qty(self):
|
||
rounding_method = self._context.get('rounding_method', 'UP')
|
||
self.product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id, rounding_method=rounding_method)
|
||
|
||
def _get_move_lines(self):
|
||
""" This will return the move lines to consider when applying _quantity_done_compute on a stock.move.
|
||
In some context, such as MRP, it is necessary to compute quantity_done on filtered sock.move.line."""
|
||
self.ensure_one()
|
||
return self.move_line_ids
|
||
|
||
@api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id')
|
||
def _quantity_done_compute(self):
|
||
for move in self:
|
||
for move_line in move._get_move_lines():
|
||
# Transform the move_line quantity_done into the move uom.
|
||
move.quantity_done += move_line.product_uom_id._compute_quantity(move_line.qty_done, move.product_uom)
|
||
|
||
def _quantity_done_set(self):
|
||
quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done`
|
||
for move in self:
|
||
move_lines = move._get_move_lines()
|
||
if not move_lines:
|
||
if quantity_done:
|
||
# do not impact reservation here
|
||
move_line = self.env['stock.move.line'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done))
|
||
move.write({'move_line_ids': [(4, move_line.id)]})
|
||
elif len(move_lines) == 1:
|
||
move_lines[0].qty_done = quantity_done
|
||
else:
|
||
raise UserError("Cannot set the done quantity from this stock move, work directly with the move lines.")
|
||
|
||
def _set_product_qty(self):
|
||
""" The meaning of product_qty field changed lately and is now a functional field computing the quantity
|
||
in the default product UoM. This code has been added to raise an error if a write is made given a value
|
||
for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to
|
||
detect errors. """
|
||
raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.'))
|
||
|
||
@api.multi
|
||
@api.depends('move_line_ids.product_qty')
|
||
def _compute_reserved_availability(self):
|
||
""" Fill the `availability` field on a stock move, which is the actual reserved quantity
|
||
and is represented by the aggregated `product_qty` on the linked move lines. If the move
|
||
is force assigned, the value will be 0.
|
||
"""
|
||
result = {data['move_id'][0]: data['product_qty'] for data in
|
||
self.env['stock.move.line'].read_group([('move_id', 'in', self.ids)], ['move_id','product_qty'], ['move_id'])}
|
||
for rec in self:
|
||
rec.reserved_availability = rec.product_id.uom_id._compute_quantity(result.get(rec.id, 0.0), rec.product_uom, rounding_method='HALF-UP')
|
||
|
||
@api.one
|
||
@api.depends('state', 'product_id', 'product_qty', 'location_id')
|
||
def _compute_product_availability(self):
|
||
""" Fill the `availability` field on a stock move, which is the quantity to potentially
|
||
reserve. When the move is done, `availability` is set to the quantity the move did actually
|
||
move.
|
||
"""
|
||
if self.state == 'done':
|
||
self.availability = self.product_qty
|
||
else:
|
||
total_availability = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id)
|
||
self.availability = min(self.product_qty, total_availability)
|
||
|
||
def _compute_string_qty_information(self):
|
||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||
void_moves = self.filtered(lambda move: move.state in ('draft', 'done', 'cancel') or move.location_id.usage != 'internal')
|
||
other_moves = self - void_moves
|
||
for move in void_moves:
|
||
move.string_availability_info = '' # 'not applicable' or 'n/a' could work too
|
||
for move in other_moves:
|
||
total_available = min(move.product_qty, move.reserved_availability + move.availability)
|
||
total_available = move.product_id.uom_id._compute_quantity(total_available, move.product_uom, round=False)
|
||
total_available = float_round(total_available, precision_digits=precision)
|
||
info = str(total_available)
|
||
if self.user_has_groups('product.group_uom'):
|
||
info += ' ' + move.product_uom.name
|
||
if move.reserved_availability:
|
||
if move.reserved_availability != total_available:
|
||
# some of the available quantity is assigned and some are available but not reserved
|
||
reserved_available = move.product_id.uom_id._compute_quantity(move.reserved_availability, move.product_uom, round=False)
|
||
reserved_available = float_round(reserved_available, precision_digits=precision)
|
||
info += _(' (%s reserved)') % str(reserved_available)
|
||
else:
|
||
# all available quantity is assigned
|
||
info += _(' (reserved)')
|
||
move.string_availability_info = info
|
||
|
||
@api.constrains('product_uom')
|
||
def _check_uom(self):
|
||
moves_error = self.filtered(lambda move: move.product_id.uom_id.category_id != move.product_uom.category_id)
|
||
if moves_error:
|
||
user_warning = _('You try to move a product using a UoM that is not compatible with the UoM of the product moved. Please use an UoM in the same UoM category.')
|
||
for move in moves_error:
|
||
user_warning += _('\n\n%s --> Product UoM is %s (%s) - Move UoM is %s (%s)') % (move.product_id.display_name, move.product_id.uom_id.name, move.product_id.uom_id.category_id.name, move.product_uom.name, move.product_uom.category_id.name)
|
||
user_warning += _('\n\nBlocking: %s') % ' ,'.join(moves_error.mapped('name'))
|
||
raise UserError(user_warning)
|
||
|
||
@api.model_cr
|
||
def init(self):
|
||
self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',))
|
||
if not self._cr.fetchone():
|
||
self._cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)')
|
||
|
||
@api.model
|
||
def default_get(self, fields_list):
|
||
# We override the default_get to make stock moves created after the picking was confirmed
|
||
# directly as available (like a force_assign). This allows to create extra move lines in
|
||
# the fp view.
|
||
defaults = super(StockMove, self).default_get(fields_list)
|
||
if self.env.context.get('default_picking_id'):
|
||
picking_id = self.env['stock.picking'].browse(self.env.context['default_picking_id'])
|
||
if picking_id.state == 'done':
|
||
defaults['state'] = 'done'
|
||
defaults['product_uom_qty'] = 0.0
|
||
defaults['additional'] = True
|
||
elif picking_id.state not in ['draft', 'confirmed']:
|
||
defaults['state'] = 'assigned'
|
||
defaults['product_uom_qty'] = 0.0
|
||
defaults['additional'] = True
|
||
return defaults
|
||
|
||
def name_get(self):
|
||
res = []
|
||
for move in self:
|
||
res.append((move.id, '%s%s%s>%s' % (
|
||
move.picking_id.origin and '%s/' % move.picking_id.origin or '',
|
||
move.product_id.code and '%s: ' % move.product_id.code or '',
|
||
move.location_id.name, move.location_dest_id.name)))
|
||
return res
|
||
|
||
def set_move_type(self):
|
||
for self in self:
|
||
if self.picking_type_id and self.picking_type_id.code == 'incoming' or\
|
||
not self.location_id._should_be_valued() and \
|
||
self.location_dest_id._should_be_valued():
|
||
self.move_type = 'in'
|
||
elif self.picking_type_id and self.picking_type_id.code == 'outgoing' \
|
||
or self.location_id._should_be_valued() and not \
|
||
self.location_dest_id._should_be_valued():
|
||
self.move_type = 'out'
|
||
|
||
@api.model
|
||
def create(self, vals):
|
||
# TDE CLEANME: why doing this tracking on picking here ? seems weird
|
||
perform_tracking = not self.env.context.get('mail_notrack') and vals.get('picking_id')
|
||
if perform_tracking:
|
||
picking = self.env['stock.picking'].browse(vals['picking_id'])
|
||
initial_values = {picking.id: {'state': picking.state}}
|
||
vals['ordered_qty'] = vals.get('product_uom_qty')
|
||
res = super(StockMove, self).create(vals)
|
||
res.set_move_type()
|
||
if perform_tracking:
|
||
picking.message_track(picking.fields_get(['state']), initial_values)
|
||
return res
|
||
|
||
def write(self, vals):
|
||
# FIXME: pim fix your crap
|
||
receipt_moves_to_reassign = self.env['stock.move']
|
||
if 'product_uom_qty' in vals:
|
||
for move in self.filtered(lambda m: m.state not in ('done', 'draft') and m.picking_id):
|
||
if vals['product_uom_qty'] != move.product_uom_qty:
|
||
self.env['stock.move.line']._log_message(move.picking_id, move, 'stock.track_move_template', vals)
|
||
if self.env.context.get('do_not_unreserve') is None:
|
||
move_to_unreserve = self.filtered(lambda m: m.state not in ['draft', 'done', 'cancel'] and m.reserved_availability > vals.get('product_uom_qty'))
|
||
move_to_unreserve._do_unreserve()
|
||
(self - move_to_unreserve).filtered(lambda m: m.state == 'assigned').write({'state': 'partially_available'})
|
||
# When editing the initial demand, directly run again action assign on receipt moves.
|
||
receipt_moves_to_reassign |= move_to_unreserve.filtered(lambda m: m.location_id.usage == 'supplier')
|
||
receipt_moves_to_reassign |= (self - move_to_unreserve).filtered(lambda m: m.location_id.usage == 'supplier' and m.state in ('partially_available', 'assigned'))
|
||
|
||
# TDE CLEANME: it is a gros bordel + tracking
|
||
Picking = self.env['stock.picking']
|
||
|
||
propagated_changes_dict = {}
|
||
#propagation of expected date:
|
||
propagated_date_field = False
|
||
if vals.get('date_expected'):
|
||
#propagate any manual change of the expected date
|
||
propagated_date_field = 'date_expected'
|
||
elif (vals.get('state', '') == 'done' and vals.get('date')):
|
||
#propagate also any delta observed when setting the move as done
|
||
propagated_date_field = 'date'
|
||
|
||
if not self._context.get('do_not_propagate', False) and (propagated_date_field or propagated_changes_dict):
|
||
#any propagation is (maybe) needed
|
||
for move in self:
|
||
if move.move_dest_ids and move.propagate:
|
||
if 'date_expected' in propagated_changes_dict:
|
||
propagated_changes_dict.pop('date_expected')
|
||
if propagated_date_field:
|
||
current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT)
|
||
new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT)
|
||
delta = relativedelta.relativedelta(new_date, current_date)
|
||
if abs(delta.days) >= move.company_id.propagation_minimum_delta:
|
||
old_move_date = datetime.strptime(move.move_dest_ids[0].date_expected, DEFAULT_SERVER_DATETIME_FORMAT)
|
||
new_move_date = (old_move_date + relativedelta.relativedelta(days=delta.days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||
propagated_changes_dict['date_expected'] = new_move_date
|
||
#For pushed moves as well as for pulled moves, propagate by recursive call of write().
|
||
#Note that, for pulled moves we intentionally don't propagate on the procurement.
|
||
if propagated_changes_dict:
|
||
move.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel')).write(propagated_changes_dict)
|
||
track_pickings = not self._context.get('mail_notrack') and any(field in vals for field in ['state', 'picking_id', 'partially_available'])
|
||
if track_pickings:
|
||
to_track_picking_ids = set([move.picking_id.id for move in self if move.picking_id])
|
||
if vals.get('picking_id'):
|
||
to_track_picking_ids.add(vals['picking_id'])
|
||
to_track_picking_ids = list(to_track_picking_ids)
|
||
pickings = Picking.browse(to_track_picking_ids)
|
||
initial_values = dict((picking.id, {'state': picking.state}) for picking in pickings)
|
||
res = super(StockMove, self).write(vals)
|
||
if track_pickings:
|
||
pickings.message_track(pickings.fields_get(['state']), initial_values)
|
||
if receipt_moves_to_reassign:
|
||
receipt_moves_to_reassign._action_assign()
|
||
return res
|
||
|
||
def action_show_details(self):
|
||
""" Returns an action that will open a form view (in a popup) allowing to work on all the
|
||
move lines of a particular move. This form view is used when "show operations" is not
|
||
checked on the picking type.
|
||
"""
|
||
self.ensure_one()
|
||
|
||
# If "show suggestions" is not checked on the picking type, we have to filter out the
|
||
# reserved move lines. We do this by displaying `move_line_nosuggest_ids`. We use
|
||
# different views to display one field or another so that the webclient doesn't have to
|
||
# fetch both.
|
||
if self.picking_id.picking_type_id.show_reserved:
|
||
view = self.env.ref('stock.view_stock_move_operations')
|
||
else:
|
||
view = self.env.ref('stock.view_stock_move_nosuggest_operations')
|
||
|
||
return {
|
||
'name': _('Detailed Operations'),
|
||
'type': 'ir.actions.act_window',
|
||
'view_type': 'form',
|
||
'view_mode': 'form',
|
||
'res_model': 'stock.move',
|
||
'views': [(view.id, 'form')],
|
||
'view_id': view.id,
|
||
'target': 'new',
|
||
'res_id': self.id,
|
||
'context': dict(
|
||
self.env.context,
|
||
show_lots_m2o=self.has_tracking != 'none' and (self.picking_type_id.use_existing_lots or self.state == 'done' or self.origin_returned_move_id.id), # able to create lots, whatever the value of ` use_create_lots`.
|
||
show_lots_text=self.has_tracking != 'none' and self.picking_type_id.use_create_lots and not self.picking_type_id.use_existing_lots and self.state != 'done' and not self.origin_returned_move_id.id,
|
||
show_source_location=self.location_id.child_ids,
|
||
show_destination_location=self.location_dest_id.child_ids,
|
||
show_package=not self.location_id.usage == 'supplier',
|
||
show_reserved_quantity=self.state != 'done'
|
||
),
|
||
}
|
||
|
||
def _do_unreserve(self):
|
||
if any(move.state in ('done', 'cancel') for move in self):
|
||
raise UserError(_('Cannot unreserve a done move'))
|
||
self.mapped('move_line_ids').unlink()
|
||
return True
|
||
|
||
def _push_apply(self):
|
||
# TDE CLEANME: I am quite sure I already saw this code somewhere ... in routing ??
|
||
Push = self.env['stock.location.path']
|
||
for move in self:
|
||
# if the move is already chained, there is no need to check push rules
|
||
if move.move_dest_ids:
|
||
continue
|
||
# if the move is a returned move, we don't want to check push rules, as returning a returned move is the only decent way
|
||
# to receive goods without triggering the push rules again (which would duplicate chained operations)
|
||
domain = [('location_from_id', '=', move.location_dest_id.id)]
|
||
# priority goes to the route defined on the product and product category
|
||
routes = move.product_id.route_ids | move.product_id.categ_id.total_route_ids
|
||
rules = Push.search(domain + [('route_id', 'in', routes.ids)], order='route_sequence, sequence', limit=1)
|
||
if not rules:
|
||
# TDE FIXME/ should those really be in a if / elif ??
|
||
# then we search on the warehouse if a rule can apply
|
||
if move.warehouse_id:
|
||
rules = Push.search(domain + [('route_id', 'in', move.warehouse_id.route_ids.ids)], order='route_sequence, sequence', limit=1)
|
||
elif move.picking_id.picking_type_id.warehouse_id:
|
||
rules = Push.search(domain + [('route_id', 'in', move.picking_id.picking_type_id.warehouse_id.route_ids.ids)], order='route_sequence, sequence', limit=1)
|
||
# Make sure it is not returning the return
|
||
if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_dest_id.id):
|
||
rules._apply(move)
|
||
|
||
def _merge_moves_fields(self):
|
||
""" This method will return a dict of stock move’s values that represent the values of all moves in `self` merged. """
|
||
state = self._get_relevant_state_among_moves()
|
||
origin = '/'.join(set(self.filtered(lambda m: m.origin).mapped('origin')))
|
||
return {
|
||
'product_uom_qty': sum(self.mapped('product_uom_qty')),
|
||
'date': min(self.mapped('date')),
|
||
'date_expected': min(self.mapped('date_expected')) if self.mapped('picking_id').move_type == 'direct' else max(self.mapped('date_expected')),
|
||
'move_dest_ids': [(4, m.id) for m in self.mapped('move_dest_ids')],
|
||
'move_orig_ids': [(4, m.id) for m in self.mapped('move_orig_ids')],
|
||
'state': state,
|
||
'origin': origin,
|
||
}
|
||
|
||
@api.model
|
||
def _prepare_merge_moves_distinct_fields(self):
|
||
return [
|
||
'product_id', 'price_unit', 'product_packaging', 'procure_method',
|
||
'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id'
|
||
]
|
||
|
||
@api.model
|
||
def _prepare_merge_move_sort_method(self, move):
|
||
move.ensure_one()
|
||
return [
|
||
move.product_id.id, move.price_unit, move.product_packaging.id, move.procure_method,
|
||
move.product_uom.id, move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id
|
||
]
|
||
|
||
def _merge_moves(self, merge_into=False):
|
||
""" This method will, for each move in `self`, go up in their linked picking and try to
|
||
find in their existing moves a candidate into which we can merge the move.
|
||
:return: Recordset of moves passed to this method. If some of the passed moves were merged
|
||
into another existing one, return this one and not the (now unlinked) original.
|
||
"""
|
||
distinct_fields = self._prepare_merge_moves_distinct_fields()
|
||
|
||
candidate_moves_list = []
|
||
if not merge_into:
|
||
for picking in self.mapped('picking_id'):
|
||
candidate_moves_list.append(picking.move_lines)
|
||
else:
|
||
candidate_moves_list.append(merge_into | self)
|
||
|
||
# Move removed after merge
|
||
moves_to_unlink = self.env['stock.move']
|
||
moves_to_merge = []
|
||
for candidate_moves in candidate_moves_list:
|
||
# First step find move to merge.
|
||
for k, g in groupby(sorted(candidate_moves, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)):
|
||
moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft'))
|
||
# If we have multiple records we will merge then in a single one.
|
||
if len(moves) > 1:
|
||
moves_to_merge.append(moves)
|
||
|
||
# second step merge its move lines, initial demand, ...
|
||
for moves in moves_to_merge:
|
||
# link all move lines to record 0 (the one we will keep).
|
||
moves.mapped('move_line_ids').write({'move_id': moves[0].id})
|
||
# merge move data
|
||
moves[0].write(moves._merge_moves_fields())
|
||
# update merged moves dicts
|
||
moves_to_unlink |= moves[1:]
|
||
|
||
if moves_to_unlink:
|
||
# We are using propagate to False in order to not cancel destination moves merged in moves[0]
|
||
moves_to_unlink.write({'propagate': False})
|
||
moves_to_unlink._action_cancel()
|
||
moves_to_unlink.unlink()
|
||
return (self | self.env['stock.move'].concat(*moves_to_merge)) - moves_to_unlink
|
||
|
||
def _get_relevant_state_among_moves(self):
|
||
# We sort our moves by importance of state:
|
||
# ------------- 0
|
||
# | Assigned |
|
||
# -------------
|
||
# | Waiting |
|
||
# -------------
|
||
# | Partial |
|
||
# -------------
|
||
# | Confirm |
|
||
# ------------- len-1
|
||
sort_map = {
|
||
'assigned': 4,
|
||
'waiting': 3,
|
||
'partially_available': 2,
|
||
'confirmed': 1,
|
||
}
|
||
moves_todo = self\
|
||
.filtered(lambda move: move.state not in ['cancel', 'done'])\
|
||
.sorted(key=lambda move: (sort_map.get(move.state, 0), move.product_uom_qty))
|
||
# The picking should be the same for all moves.
|
||
if moves_todo[0].picking_id.move_type == 'one':
|
||
most_important_move = moves_todo[0]
|
||
if most_important_move.state == 'confirmed':
|
||
return 'confirmed' if most_important_move.product_uom_qty else 'assigned'
|
||
elif most_important_move.state == 'partially_available':
|
||
return 'confirmed'
|
||
else:
|
||
return moves_todo[0].state or 'draft'
|
||
elif moves_todo[0].state != 'assigned' and any(move.state in ['assigned', 'partially_available'] for move in moves_todo):
|
||
return 'partially_available'
|
||
else:
|
||
least_important_move = moves_todo[-1]
|
||
if least_important_move.state == 'confirmed' and least_important_move.product_uom_qty == 0:
|
||
return 'assigned'
|
||
else:
|
||
return moves_todo[-1].state or 'draft'
|
||
|
||
@api.onchange('product_id', 'product_qty')
|
||
def onchange_quantity(self):
|
||
if not self.product_id or self.product_qty < 0.0:
|
||
self.product_qty = 0.0
|
||
if self.product_qty < self._origin.product_qty:
|
||
warning_mess = {
|
||
'title': _('Quantity decreased!'),
|
||
'message' : _("By changing this quantity here, you accept the "
|
||
"new quantity as complete: Flectra will not "
|
||
"automatically generate a back order."),
|
||
}
|
||
return {'warning': warning_mess}
|
||
|
||
@api.onchange('product_id')
|
||
def onchange_product_id(self):
|
||
product = self.product_id.with_context(lang=self.partner_id.lang or self.env.user.lang)
|
||
self.name = product.partner_ref
|
||
self.product_uom = product.uom_id.id
|
||
return {'domain': {'product_uom': [('category_id', '=', product.uom_id.category_id.id)]}}
|
||
|
||
@api.onchange('date')
|
||
def onchange_date(self):
|
||
if self.date_expected:
|
||
self.date = self.date_expected
|
||
|
||
@api.onchange('product_uom')
|
||
def onchange_product_uom(self):
|
||
if self.product_uom.factor > self.product_id.uom_id.factor:
|
||
return {
|
||
'warning': {
|
||
'title': "Unsafe unit of measure",
|
||
'message': _("You are using a unit of measure smaller than the one you are using in "
|
||
"order to stock your product. This can lead to rounding problem on reserved quantity! "
|
||
"You should use the smaller unit of measure possible in order to valuate your stock or "
|
||
"change its rounding precision to a smaller value (example: 0.00001)."),
|
||
}
|
||
}
|
||
|
||
def _assign_picking(self):
|
||
""" Try to assign the moves to an existing picking that has not been
|
||
reserved yet and has the same procurement group, locations and picking
|
||
type (moves should already have them identical). Otherwise, create a new
|
||
picking to assign them to. """
|
||
Picking = self.env['stock.picking']
|
||
for move in self:
|
||
recompute = False
|
||
picking = Picking.search([
|
||
('group_id', '=', move.group_id.id),
|
||
('location_id', '=', move.location_id.id),
|
||
('location_dest_id', '=', move.location_dest_id.id),
|
||
('picking_type_id', '=', move.picking_type_id.id),
|
||
('printed', '=', False),
|
||
('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1)
|
||
if picking:
|
||
if picking.partner_id.id != move.partner_id.id or picking.origin != move.origin:
|
||
# If a picking is found, we'll append `move` to its move list and thus its
|
||
# `partner_id` and `ref` field will refer to multiple records. In this
|
||
# case, we chose to wipe them.
|
||
picking.write({
|
||
'partner_id': False,
|
||
'origin': False,
|
||
})
|
||
else:
|
||
recompute = True
|
||
picking = Picking.create(move._get_new_picking_values())
|
||
move.write({'picking_id': picking.id})
|
||
|
||
# If this method is called in batch by a write on a one2many and
|
||
# at some point had to create a picking, some next iterations could
|
||
# try to find back the created picking. As we look for it by searching
|
||
# on some computed fields, we have to force a recompute, else the
|
||
# record won't be found.
|
||
if recompute:
|
||
move.recompute()
|
||
return True
|
||
|
||
def _get_new_picking_values(self):
|
||
""" Prepares a new picking for this move as it could not be assigned to
|
||
another picking. This method is designed to be inherited. """
|
||
return {
|
||
'origin': self.origin,
|
||
'company_id': self.company_id.id,
|
||
'move_type': self.group_id and self.group_id.move_type or 'direct',
|
||
'partner_id': self.partner_id.id,
|
||
'picking_type_id': self.picking_type_id.id,
|
||
'location_id': self.location_id.id,
|
||
'location_dest_id': self.location_dest_id.id,
|
||
}
|
||
|
||
def _action_confirm(self, merge=True, merge_into=False):
|
||
""" Confirms stock move or put it in waiting if it's linked to another move.
|
||
:param: merge: According to this boolean, a newly confirmed move will be merged
|
||
in another move of the same picking sharing its characteristics.
|
||
"""
|
||
move_create_proc = self.env['stock.move']
|
||
move_to_confirm = self.env['stock.move']
|
||
move_waiting = self.env['stock.move']
|
||
|
||
to_assign = {}
|
||
for move in self:
|
||
# if the move is preceeded, then it's waiting (if preceeding move is done, then action_assign has been called already and its state is already available)
|
||
if move.move_orig_ids:
|
||
move_waiting |= move
|
||
else:
|
||
if move.procure_method == 'make_to_order':
|
||
move_create_proc |= move
|
||
else:
|
||
move_to_confirm |= move
|
||
if not move.picking_id and move.picking_type_id:
|
||
key = (move.group_id.id, move.location_id.id, move.location_dest_id.id)
|
||
if key not in to_assign:
|
||
to_assign[key] = self.env['stock.move']
|
||
to_assign[key] |= move
|
||
|
||
# create procurements for make to order moves
|
||
for move in move_create_proc:
|
||
values = move._prepare_procurement_values()
|
||
origin = (move.group_id and move.group_id.name or (move.rule_id and move.rule_id.name or move.origin or move.picking_id.name or "/"))
|
||
self.env['procurement.group'].run(move.product_id, move.product_uom_qty, move.product_uom, move.location_id, move.rule_id and move.rule_id.name or "/", origin,
|
||
values)
|
||
|
||
move_to_confirm.write({'state': 'confirmed'})
|
||
(move_waiting | move_create_proc).write({'state': 'waiting'})
|
||
|
||
# assign picking in batch for all confirmed move that share the same details
|
||
for moves in to_assign.values():
|
||
moves._assign_picking()
|
||
self._push_apply()
|
||
if merge:
|
||
return self._merge_moves(merge_into=merge_into)
|
||
return self
|
||
|
||
def _prepare_procurement_values(self):
|
||
""" Prepare specific key for moves or other componenets that will be created from a procurement rule
|
||
comming from a stock move. This method could be override in order to add other custom key that could
|
||
be used in move/po creation.
|
||
"""
|
||
self.ensure_one()
|
||
group_id = self.group_id or False
|
||
if self.rule_id:
|
||
if self.rule_id.group_propagation_option == 'fixed' and self.rule_id.group_id:
|
||
group_id = self.rule_id.group_id
|
||
elif self.rule_id.group_propagation_option == 'none':
|
||
group_id = False
|
||
return {
|
||
'company_id': self.company_id,
|
||
'date_planned': self.date,
|
||
'move_dest_ids': self,
|
||
'group_id': group_id,
|
||
'route_ids': self.route_ids,
|
||
'warehouse_id': self.warehouse_id or self.picking_id.picking_type_id.warehouse_id or self.picking_type_id.warehouse_id,
|
||
'priority': self.priority,
|
||
}
|
||
|
||
def _force_assign(self):
|
||
""" Allow to work on stock move lines even if the reservationis not possible. We just mark
|
||
the move as assigned, so the view does not block the user.
|
||
"""
|
||
for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available', 'assigned']):
|
||
move.write({'state': 'assigned'})
|
||
|
||
def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
|
||
self.ensure_one()
|
||
# apply putaway
|
||
location_dest_id = self.location_dest_id.get_putaway_strategy(self.product_id).id or self.location_dest_id.id
|
||
vals = {
|
||
'move_id': self.id,
|
||
'product_id': self.product_id.id,
|
||
'product_uom_id': self.product_uom.id,
|
||
'location_id': self.location_id.id,
|
||
'location_dest_id': location_dest_id,
|
||
'picking_id': self.picking_id.id,
|
||
}
|
||
if quantity:
|
||
uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
|
||
vals = dict(vals, product_uom_qty=uom_quantity)
|
||
if reserved_quant:
|
||
vals = dict(
|
||
vals,
|
||
location_id=reserved_quant.location_id.id,
|
||
lot_id=reserved_quant.lot_id.id or False,
|
||
package_id=reserved_quant.package_id.id or False,
|
||
owner_id =reserved_quant.owner_id.id or False,
|
||
)
|
||
return vals
|
||
|
||
def _update_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True):
|
||
""" Create or update move lines.
|
||
"""
|
||
self.ensure_one()
|
||
|
||
if not lot_id:
|
||
lot_id = self.env['stock.production.lot']
|
||
if not package_id:
|
||
package_id = self.env['stock.quant.package']
|
||
if not owner_id:
|
||
owner_id = self.env['res.partner']
|
||
|
||
taken_quantity = min(available_quantity, need)
|
||
|
||
quants = []
|
||
try:
|
||
quants = self.env['stock.quant']._update_reserved_quantity(
|
||
self.product_id, location_id, taken_quantity, lot_id=lot_id,
|
||
package_id=package_id, owner_id=owner_id, strict=strict
|
||
)
|
||
except UserError:
|
||
# If it raises here, it means that the `available_quantity` brought by a done move line
|
||
# is not available on the quants itself. This could be the result of an inventory
|
||
# adjustment that removed totally of partially `available_quantity`. When this happens, we
|
||
# chose to do nothing. This situation could not happen on MTS move, because in this case
|
||
# `available_quantity` is directly the quantity on the quants themselves.
|
||
taken_quantity = 0
|
||
|
||
# Find a candidate move line to update or create a new one.
|
||
for reserved_quant, quantity in quants:
|
||
to_update = self.move_line_ids.filtered(lambda m: m.product_id.tracking != 'serial' and
|
||
m.location_id.id == reserved_quant.location_id.id and m.lot_id.id == reserved_quant.lot_id.id and m.package_id.id == reserved_quant.package_id.id and m.owner_id.id == reserved_quant.owner_id.id)
|
||
if to_update:
|
||
to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
|
||
else:
|
||
if self.product_id.tracking == 'serial':
|
||
for i in range(0, int(quantity)):
|
||
self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant))
|
||
else:
|
||
self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant))
|
||
return taken_quantity
|
||
|
||
def _action_assign(self):
|
||
""" Reserve stock moves by creating their stock move lines. A stock move is
|
||
considered reserved once the sum of `product_qty` for all its move lines is
|
||
equal to its `product_qty`. If it is less, the stock move is considered
|
||
partially available.
|
||
"""
|
||
assigned_moves = self.env['stock.move']
|
||
partially_available_moves = self.env['stock.move']
|
||
for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']):
|
||
if move.location_id.usage in ('supplier', 'inventory', 'production', 'customer')\
|
||
or move.product_id.type == 'consu':
|
||
# create the move line(s) but do not impact quants
|
||
if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots):
|
||
for i in range(0, int(move.product_qty - move.reserved_availability)):
|
||
self.env['stock.move.line'].create(move._prepare_move_line_vals(quantity=1))
|
||
else:
|
||
to_update = move.move_line_ids.filtered(lambda ml: ml.product_uom_id == move.product_uom and
|
||
ml.location_id == move.location_id and
|
||
ml.location_dest_id == move.location_dest_id and
|
||
ml.picking_id == move.picking_id and
|
||
not ml.lot_id and
|
||
not ml.package_id and
|
||
not ml.owner_id)
|
||
if to_update:
|
||
to_update[0].product_uom_qty += move.product_qty - move.reserved_availability
|
||
else:
|
||
self.env['stock.move.line'].create(move._prepare_move_line_vals(quantity=move.product_qty - move.reserved_availability))
|
||
assigned_moves |= move
|
||
else:
|
||
if not move.move_orig_ids:
|
||
if move.procure_method == 'make_to_order':
|
||
continue
|
||
# Reserve new quants and create move lines accordingly.
|
||
available_quantity = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id)
|
||
if available_quantity <= 0:
|
||
continue
|
||
need = move.product_qty - move.reserved_availability
|
||
taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, strict=False)
|
||
if need == taken_quantity:
|
||
assigned_moves |= move
|
||
else:
|
||
partially_available_moves |= move
|
||
else:
|
||
# Check what our parents brought and what our siblings took in order to
|
||
# determine what we can distribute.
|
||
# `qty_done` is in `ml.product_uom_id` and, as we will later increase
|
||
# the reserved quantity on the quants, convert it here in
|
||
# `product_id.uom_id` (the UOM of the quants is the UOM of the product).
|
||
move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids')
|
||
keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id']
|
||
|
||
def _keys_in_sorted(ml):
|
||
return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id)
|
||
|
||
grouped_move_lines_in = {}
|
||
for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)):
|
||
qty_done = 0
|
||
for ml in g:
|
||
qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
|
||
grouped_move_lines_in[k] = qty_done
|
||
move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\
|
||
.filtered(lambda m: m.state in ['done'])\
|
||
.mapped('move_line_ids')
|
||
# As we defer the write on the stock.move's state at the end of the loop, there
|
||
# could be moves to consider in what our siblings already took.
|
||
moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move
|
||
moves_out_siblings_to_consider = moves_out_siblings & (assigned_moves + partially_available_moves)
|
||
reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned'])
|
||
move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids')
|
||
keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id']
|
||
|
||
def _keys_out_sorted(ml):
|
||
return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id)
|
||
|
||
grouped_move_lines_out = {}
|
||
for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
|
||
qty_done = 0
|
||
for ml in g:
|
||
qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
|
||
grouped_move_lines_out[k] = qty_done
|
||
for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
|
||
grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
|
||
available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()}
|
||
# pop key if the quantity available amount to 0
|
||
available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v)
|
||
|
||
if not available_move_lines:
|
||
continue
|
||
for move_line in move.move_line_ids.filtered(lambda m: m.product_qty):
|
||
if available_move_lines.get((move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)):
|
||
available_move_lines[(move_line.location_id, move_line.lot_id, move_line.result_package_id, move_line.owner_id)] -= move_line.product_qty
|
||
for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items():
|
||
need = move.product_qty - sum(move.move_line_ids.mapped('product_qty'))
|
||
taken_quantity = move._update_reserved_quantity(need, quantity, location_id, lot_id, package_id, owner_id)
|
||
if need - taken_quantity == 0.0:
|
||
assigned_moves |= move
|
||
break
|
||
partially_available_moves |= move
|
||
partially_available_moves.write({'state': 'partially_available'})
|
||
assigned_moves.write({'state': 'assigned'})
|
||
self.mapped('picking_id')._check_entire_pack()
|
||
|
||
def _action_cancel(self):
|
||
if any(move.state == 'done' for move in self):
|
||
raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.'))
|
||
for move in self:
|
||
if move.state == 'cancel':
|
||
continue
|
||
move._do_unreserve()
|
||
siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state')
|
||
if move.propagate:
|
||
# only cancel the next move if all my siblings are also cancelled
|
||
if all(state == 'cancel' for state in siblings_states):
|
||
move.move_dest_ids._action_cancel()
|
||
else:
|
||
if all(state in ('done', 'cancel') for state in siblings_states):
|
||
move.move_dest_ids.write({'procure_method': 'make_to_stock'})
|
||
move.move_dest_ids.write({'move_orig_ids': [(3, move.id, 0)]})
|
||
self.write({'state': 'cancel', 'move_orig_ids': [(5, 0, 0)]})
|
||
return True
|
||
|
||
def _prepare_extra_move_vals(self, qty):
|
||
vals = {
|
||
'product_uom_qty': qty,
|
||
'picking_id': self.picking_id.id,
|
||
'price_unit': self.price_unit,
|
||
}
|
||
return vals
|
||
|
||
def _create_extra_move(self):
|
||
""" If the quantity done on a move exceeds its quantity todo, this method will create an
|
||
extra move attached to a (potentially split) move line. If the previous condition is not
|
||
met, it'll return an empty recordset.
|
||
|
||
The rationale for the creation of an extra move is the application of a potential push
|
||
rule that will handle the extra quantities.
|
||
"""
|
||
extra_move = self
|
||
rounding = self.product_uom.rounding
|
||
# moves created after the picking is assigned do not have `product_uom_qty`, but we shouldn't create extra moves for them
|
||
if float_compare(self.quantity_done, self.product_uom_qty, precision_rounding=rounding) > 0:
|
||
# create the extra moves
|
||
extra_move_quantity = float_round(
|
||
self.quantity_done - self.product_uom_qty,
|
||
precision_rounding=self.product_uom.rounding,
|
||
rounding_method ='UP')
|
||
extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity)
|
||
extra_move = self.copy(default=extra_move_vals)
|
||
if extra_move.picking_id:
|
||
extra_move = extra_move._action_confirm(merge_into=self)
|
||
else:
|
||
extra_move = extra_move._action_confirm()
|
||
|
||
# link it to some move lines. We don't need to do it for move since they should be merged.
|
||
if self.exists() and not self.picking_id:
|
||
for move_line in self.move_line_ids.filtered(lambda ml: ml.qty_done):
|
||
if float_compare(move_line.qty_done, extra_move_quantity, precision_rounding=rounding) <= 0:
|
||
# move this move line to our extra move
|
||
move_line.move_id = extra_move.id
|
||
extra_move_quantity -= move_line.qty_done
|
||
else:
|
||
# split this move line and assign the new part to our extra move
|
||
quantity_split = float_round(
|
||
move_line.qty_done - extra_move_quantity,
|
||
precision_rounding=self.product_uom.rounding,
|
||
rounding_method='UP')
|
||
move_line.qty_done = quantity_split
|
||
move_line.copy(default={'move_id': extra_move.id, 'qty_done': extra_move_quantity, 'product_uom_qty': 0})
|
||
extra_move_quantity -= extra_move_quantity
|
||
if extra_move_quantity == 0.0:
|
||
break
|
||
return extra_move
|
||
|
||
def check_move_bal_qty(self):
|
||
for move in self:
|
||
if move.move_type == 'in':
|
||
move.bal_qty = move.product_uom_qty
|
||
if move.move_type == 'out':
|
||
prev_in_moves = self.search([
|
||
('product_id', '=', move.product_id.id),
|
||
('state', '=', 'done'),
|
||
('move_type', '=', 'in'),
|
||
('bal_qty', '>', '0'),
|
||
], order='date')
|
||
rem_qty = move.product_uom_qty
|
||
for pre_move in prev_in_moves:
|
||
if rem_qty:
|
||
pre_move.out_qty = move.product_uom_qty
|
||
if rem_qty > pre_move.bal_qty:
|
||
rem_qty -= pre_move.bal_qty
|
||
pre_move.bal_qty = 0
|
||
else:
|
||
pre_move.bal_qty -= rem_qty
|
||
rem_qty -= rem_qty
|
||
|
||
def _action_done(self):
|
||
self.filtered(lambda move: move.state == 'draft')._action_confirm() # MRP allows scrapping draft moves
|
||
|
||
moves = self.filtered(lambda x: x.state not in ('done', 'cancel'))
|
||
moves_todo = self.env['stock.move']
|
||
|
||
# Cancel moves where necessary ; we should do it before creating the extra moves because
|
||
# this operation could trigger a merge of moves.
|
||
for move in moves:
|
||
if move.quantity_done <= 0:
|
||
if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0:
|
||
move._action_cancel()
|
||
|
||
# Create extra moves where necessary
|
||
for move in moves:
|
||
if move.state == 'cancel' or move.quantity_done <= 0:
|
||
continue
|
||
# extra move will not be merged in mrp
|
||
if not move.picking_id:
|
||
moves_todo |= move
|
||
moves_todo |= move._create_extra_move()
|
||
|
||
# Split moves where necessary and move quants
|
||
for move in moves_todo:
|
||
rounding = move.product_uom.rounding
|
||
if float_compare(move.quantity_done, move.product_uom_qty, precision_rounding=rounding) < 0:
|
||
# Need to do some kind of conversion here
|
||
qty_split = move.product_uom._compute_quantity(move.product_uom_qty - move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')
|
||
new_move = move._split(qty_split)
|
||
for move_line in move.move_line_ids:
|
||
if move_line.product_qty and move_line.qty_done:
|
||
# FIXME: there will be an issue if the move was partially available
|
||
# By decreasing `product_qty`, we free the reservation.
|
||
# FIXME: if qty_done > product_qty, this could raise if nothing is in stock
|
||
try:
|
||
move_line.write({'product_uom_qty': move_line.qty_done})
|
||
except UserError:
|
||
pass
|
||
|
||
# If you were already putting stock.move.lots on the next one in the work order, transfer those to the new move
|
||
move.move_line_ids.filtered(lambda x: x.qty_done == 0.0).write({'move_id': new_move})
|
||
move.move_line_ids._action_done()
|
||
# Check the consistency of the result packages; there should be an unique location across
|
||
# the contained quants.
|
||
for result_package in moves_todo\
|
||
.mapped('move_line_ids.result_package_id')\
|
||
.filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1):
|
||
if len(result_package.quant_ids.mapped('location_id')) > 1:
|
||
raise UserError(_('You should not put the contents of a package in different locations.'))
|
||
picking = moves_todo and moves_todo[0].picking_id or False
|
||
moves_todo.write({'state': 'done', 'date': fields.Datetime.now()})
|
||
moves_todo.mapped('move_dest_ids')._action_assign()
|
||
|
||
# We don't want to create back order for scrap moves
|
||
if all(move_todo.scrapped for move_todo in moves_todo):
|
||
return moves_todo
|
||
|
||
if picking:
|
||
picking._create_backorder()
|
||
|
||
#calculate balance quantity & out qty to track the age of each stock
|
||
moves_todo.check_move_bal_qty()
|
||
|
||
return moves_todo
|
||
|
||
def unlink(self):
|
||
if any(move.state not in ('draft', 'cancel') for move in self):
|
||
raise UserError(_('You can only delete draft moves.'))
|
||
# With the non plannified picking, draft moves could have some move lines.
|
||
self.mapped('move_line_ids').unlink()
|
||
return super(StockMove, self).unlink()
|
||
|
||
def _prepare_move_split_vals(self, uom_qty):
|
||
vals = {
|
||
'product_uom_qty': uom_qty,
|
||
'procure_method': 'make_to_stock',
|
||
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
|
||
'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
|
||
'origin_returned_move_id': self.origin_returned_move_id.id,
|
||
'price_unit': self.price_unit,
|
||
}
|
||
return vals
|
||
|
||
def _split(self, qty, restrict_partner_id=False):
|
||
""" Splits qty from move move into a new move
|
||
|
||
:param qty: float. quantity to split (given in product UoM)
|
||
:param restrict_partner_id: optional partner that can be given in order to force the new move to restrict its choice of quants to the ones belonging to this partner.
|
||
:param context: dictionay. can contains the special key 'source_location_id' in order to force the source location when copying the move
|
||
:returns: id of the backorder move created """
|
||
self = self.with_prefetch() # This makes the ORM only look for one record and not 300 at a time, which improves performance
|
||
if self.state in ('done', 'cancel'):
|
||
raise UserError(_('You cannot split a move done'))
|
||
elif self.state == 'draft':
|
||
# we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in
|
||
# case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode.
|
||
raise UserError(_('You cannot split a draft move. It needs to be confirmed first.'))
|
||
if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty:
|
||
return self.id
|
||
# HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM
|
||
uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP')
|
||
defaults = self._prepare_move_split_vals(uom_qty)
|
||
|
||
if restrict_partner_id:
|
||
defaults['restrict_partner_id'] = restrict_partner_id
|
||
|
||
# TDE CLEANME: remove context key + add as parameter
|
||
if self.env.context.get('source_location_id'):
|
||
defaults['location_id'] = self.env.context['source_location_id']
|
||
new_move = self.with_context(rounding_method='HALF-UP').copy(defaults)
|
||
# ctx = context.copy()
|
||
# TDE CLEANME: used only in write in this file, to clean
|
||
# ctx['do_not_propagate'] = True
|
||
|
||
# FIXME: pim fix your crap
|
||
self.with_context(do_not_propagate=True, do_not_unreserve=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty})
|
||
|
||
# if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'):
|
||
# new_move_prop = self.move_dest_id.split(qty)
|
||
# new_move.write({'move_dest_id': new_move_prop})
|
||
# returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and
|
||
# thus the result of action_confirm should always be a list of 1 element length)
|
||
# In this case we don't merge move since the new move with 0 quantity done will be used for the backorder.
|
||
new_move = new_move._action_confirm(merge=False)
|
||
# TDE FIXME: due to action confirm change
|
||
return new_move.id
|
||
|
||
def _recompute_state(self):
|
||
for move in self:
|
||
if move.reserved_availability == move.product_uom_qty:
|
||
move.state = 'assigned'
|
||
elif move.reserved_availability and move.reserved_availability <= move.product_uom_qty:
|
||
move.state = 'partially_available'
|
||
else:
|
||
if move.procure_method == 'make_to_order' and not move.move_orig_ids:
|
||
move.state = 'waiting'
|
||
elif move.move_orig_ids and not all(orig.state in ('done', 'cancel') for orig in move.move_orig_ids):
|
||
move.state = 'waiting'
|
||
else:
|
||
move.state = 'confirmed'
|