summaryrefslogtreecommitdiff
path: root/addons/stock/models/stock_move_line.py
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/stock/models/stock_move_line.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/stock/models/stock_move_line.py')
-rw-r--r--addons/stock/models/stock_move_line.py676
1 files changed, 676 insertions, 0 deletions
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
new file mode 100644
index 00000000..b408d861
--- /dev/null
+++ b/addons/stock/models/stock_move_line.py
@@ -0,0 +1,676 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import Counter
+
+from odoo import _, api, fields, tools, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import OrderedSet
+from odoo.tools.float_utils import float_compare, float_is_zero, float_round
+
+
+class StockMoveLine(models.Model):
+ _name = "stock.move.line"
+ _description = "Product Moves (Stock Move Line)"
+ _rec_name = "product_id"
+ _order = "result_package_id desc, id"
+
+ picking_id = fields.Many2one(
+ 'stock.picking', 'Transfer', auto_join=True,
+ check_company=True,
+ index=True,
+ help='The stock operation where the packing has been made')
+ move_id = fields.Many2one(
+ 'stock.move', 'Stock Move',
+ check_company=True,
+ help="Change to a better name", index=True)
+ company_id = fields.Many2one('res.company', string='Company', readonly=True, required=True, index=True)
+ product_id = fields.Many2one('product.product', 'Product', ondelete="cascade", check_company=True, domain="[('type', '!=', 'service'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True, domain="[('category_id', '=', product_uom_category_id)]")
+ product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
+ product_qty = fields.Float(
+ 'Real Reserved Quantity', digits=0, copy=False,
+ compute='_compute_product_qty', inverse='_set_product_qty', store=True)
+ product_uom_qty = fields.Float(
+ 'Reserved', default=0.0, digits='Product Unit of Measure', required=True, copy=False)
+ qty_done = fields.Float('Done', default=0.0, digits='Product Unit of Measure', copy=False)
+ package_id = fields.Many2one(
+ 'stock.quant.package', 'Source Package', ondelete='restrict',
+ check_company=True,
+ domain="[('location_id', '=', location_id)]")
+ package_level_id = fields.Many2one('stock.package_level', 'Package Level', check_company=True)
+ lot_id = fields.Many2one(
+ 'stock.production.lot', 'Lot/Serial Number',
+ domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]", check_company=True)
+ lot_name = fields.Char('Lot/Serial Number Name')
+ result_package_id = fields.Many2one(
+ 'stock.quant.package', 'Destination Package',
+ ondelete='restrict', required=False, check_company=True,
+ domain="['|', '|', ('location_id', '=', False), ('location_id', '=', location_dest_id), ('id', '=', package_id)]",
+ help="If set, the operations are packed into this package")
+ date = fields.Datetime('Date', default=fields.Datetime.now, required=True)
+ owner_id = fields.Many2one(
+ 'res.partner', 'From Owner',
+ check_company=True,
+ help="When validating the transfer, the products will be taken from this owner.")
+ location_id = fields.Many2one('stock.location', 'From', check_company=True, required=True)
+ location_dest_id = fields.Many2one('stock.location', 'To', check_company=True, required=True)
+ lots_visible = fields.Boolean(compute='_compute_lots_visible')
+ picking_code = fields.Selection(related='picking_id.picking_type_id.code', readonly=True)
+ picking_type_use_create_lots = fields.Boolean(related='picking_id.picking_type_id.use_create_lots', readonly=True)
+ picking_type_use_existing_lots = fields.Boolean(related='picking_id.picking_type_id.use_existing_lots', readonly=True)
+ state = fields.Selection(related='move_id.state', store=True, related_sudo=False)
+ is_initial_demand_editable = fields.Boolean(related='move_id.is_initial_demand_editable', readonly=False)
+ is_locked = fields.Boolean(related='move_id.is_locked', default=True, readonly=True)
+ consume_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'consume_line_id', 'produce_line_id', help="Technical link to see who consumed what. ")
+ produce_line_ids = fields.Many2many('stock.move.line', 'stock_move_line_consume_rel', 'produce_line_id', 'consume_line_id', help="Technical link to see which line was produced with this. ")
+ reference = fields.Char(related='move_id.reference', store=True, related_sudo=False, readonly=False)
+ tracking = fields.Selection(related='product_id.tracking', readonly=True)
+ origin = fields.Char(related='move_id.origin', string='Source')
+ picking_type_entire_packs = fields.Boolean(related='picking_id.picking_type_id.show_entire_packs', readonly=True)
+ description_picking = fields.Text(string="Description picking")
+
+ @api.depends('picking_id.picking_type_id', 'product_id.tracking')
+ def _compute_lots_visible(self):
+ for line in self:
+ picking = line.picking_id
+ if picking.picking_type_id and line.product_id.tracking != 'none': # TDE FIXME: not sure correctly migrated
+ line.lots_visible = picking.picking_type_id.use_existing_lots or picking.picking_type_id.use_create_lots
+ else:
+ line.lots_visible = line.product_id.tracking != 'none'
+
+ @api.depends('product_id', 'product_uom_id', 'product_uom_qty')
+ def _compute_product_qty(self):
+ for line in self:
+ line.product_qty = line.product_uom_id._compute_quantity(line.product_uom_qty, line.product_id.uom_id, rounding_method='HALF-UP')
+
+ 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.constrains('lot_id', 'product_id')
+ def _check_lot_product(self):
+ for line in self:
+ if line.lot_id and line.product_id != line.lot_id.sudo().product_id:
+ raise ValidationError(_(
+ 'This lot %(lot_name)s is incompatible with this product %(product_name)s',
+ lot_name=line.lot_id.name,
+ product_name=line.product_id.display_name
+ ))
+
+ @api.constrains('product_uom_qty')
+ def _check_reserved_done_quantity(self):
+ for move_line in self:
+ if move_line.state == 'done' and not float_is_zero(move_line.product_uom_qty, precision_digits=self.env['decimal.precision'].precision_get('Product Unit of Measure')):
+ raise ValidationError(_('A done move line should never have a reserved quantity.'))
+
+ @api.constrains('qty_done')
+ def _check_positive_qty_done(self):
+ if any([ml.qty_done < 0 for ml in self]):
+ raise ValidationError(_('You can not enter negative quantities.'))
+
+ @api.onchange('product_id', 'product_uom_id')
+ def _onchange_product_id(self):
+ if self.product_id:
+ if not self.id and self.user_has_groups('stock.group_stock_multi_locations'):
+ self.location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id) or self.location_dest_id
+ if self.picking_id:
+ product = self.product_id.with_context(lang=self.picking_id.partner_id.lang or self.env.user.lang)
+ self.description_picking = product._get_description(self.picking_id.picking_type_id)
+ self.lots_visible = self.product_id.tracking != 'none'
+ if not self.product_uom_id or self.product_uom_id.category_id != self.product_id.uom_id.category_id:
+ if self.move_id.product_uom:
+ self.product_uom_id = self.move_id.product_uom.id
+ else:
+ self.product_uom_id = self.product_id.uom_id.id
+
+ @api.onchange('lot_name', 'lot_id')
+ def _onchange_serial_number(self):
+ """ When the user is encoding a move line for a tracked product, we apply some logic to
+ help him. This includes:
+ - automatically switch `qty_done` to 1.0
+ - warn if he has already encoded `lot_name` in another move line
+ """
+ res = {}
+ if self.product_id.tracking == 'serial':
+ if not self.qty_done:
+ self.qty_done = 1
+
+ message = None
+ if self.lot_name or self.lot_id:
+ move_lines_to_check = self._get_similar_move_lines() - self
+ if self.lot_name:
+ counter = Counter([line.lot_name for line in move_lines_to_check])
+ if counter.get(self.lot_name) and counter[self.lot_name] > 1:
+ message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
+ elif not self.lot_id:
+ counter = self.env['stock.production.lot'].search_count([
+ ('company_id', '=', self.company_id.id),
+ ('product_id', '=', self.product_id.id),
+ ('name', '=', self.lot_name),
+ ])
+ if counter > 0:
+ message = _('Existing Serial number (%s). Please correct the serial number encoded.') % self.lot_name
+ elif self.lot_id:
+ counter = Counter([line.lot_id.id for line in move_lines_to_check])
+ if counter.get(self.lot_id.id) and counter[self.lot_id.id] > 1:
+ message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
+ if message:
+ res['warning'] = {'title': _('Warning'), 'message': message}
+ return res
+
+ @api.onchange('qty_done', 'product_uom_id')
+ def _onchange_qty_done(self):
+ """ When the user is encoding a move line for a tracked product, we apply some logic to
+ help him. This onchange will warn him if he set `qty_done` to a non-supported value.
+ """
+ res = {}
+ if self.qty_done and self.product_id.tracking == 'serial':
+ qty_done = self.product_uom_id._compute_quantity(self.qty_done, self.product_id.uom_id)
+ if float_compare(qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0:
+ message = _('You can only process 1.0 %s of products with unique serial number.', self.product_id.uom_id.name)
+ res['warning'] = {'title': _('Warning'), 'message': message}
+ return res
+
+ def init(self):
+ if not tools.index_exists(self._cr, 'stock_move_line_free_reservation_index'):
+ self._cr.execute("""
+ CREATE INDEX stock_move_line_free_reservation_index
+ ON
+ stock_move_line (id, company_id, product_id, lot_id, location_id, owner_id, package_id)
+ WHERE
+ (state IS NULL OR state NOT IN ('cancel', 'done')) AND product_qty > 0""")
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('move_id'):
+ vals['company_id'] = self.env['stock.move'].browse(vals['move_id']).company_id.id
+ elif vals.get('picking_id'):
+ vals['company_id'] = self.env['stock.picking'].browse(vals['picking_id']).company_id.id
+
+ mls = super().create(vals_list)
+
+ def create_move(move_line):
+ new_move = self.env['stock.move'].create({
+ 'name': _('New Move:') + move_line.product_id.display_name,
+ 'product_id': move_line.product_id.id,
+ 'product_uom_qty': 0 if move_line.picking_id and move_line.picking_id.state != 'done' else move_line.qty_done,
+ 'product_uom': move_line.product_uom_id.id,
+ 'description_picking': move_line.description_picking,
+ 'location_id': move_line.picking_id.location_id.id,
+ 'location_dest_id': move_line.picking_id.location_dest_id.id,
+ 'picking_id': move_line.picking_id.id,
+ 'state': move_line.picking_id.state,
+ 'picking_type_id': move_line.picking_id.picking_type_id.id,
+ 'restrict_partner_id': move_line.picking_id.owner_id.id,
+ 'company_id': move_line.picking_id.company_id.id,
+ })
+ move_line.move_id = new_move.id
+
+ # If the move line is directly create on the picking view.
+ # If this picking is already done we should generate an
+ # associated done move.
+ for move_line in mls:
+ if move_line.move_id or not move_line.picking_id:
+ continue
+ if move_line.picking_id.state != 'done':
+ moves = move_line.picking_id.move_lines.filtered(lambda x: x.product_id == move_line.product_id)
+ moves = sorted(moves, key=lambda m: m.quantity_done < m.product_qty, reverse=True)
+ if moves:
+ move_line.move_id = moves[0].id
+ else:
+ create_move(move_line)
+ else:
+ create_move(move_line)
+
+ for ml, vals in zip(mls, vals_list):
+ if ml.move_id and \
+ ml.move_id.picking_id and \
+ ml.move_id.picking_id.immediate_transfer and \
+ ml.move_id.state != 'done' and \
+ 'qty_done' in vals:
+ ml.move_id.product_uom_qty = ml.move_id.quantity_done
+ if ml.state == 'done':
+ if 'qty_done' in vals:
+ ml.move_id.product_uom_qty = ml.move_id.quantity_done
+ if ml.product_id.type == 'product':
+ Quant = self.env['stock.quant']
+ quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,rounding_method='HALF-UP')
+ in_date = None
+ available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ if available_qty < 0 and ml.lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(quantity))
+ Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
+ next_moves = ml.move_id.move_dest_ids.filtered(lambda move: move.state not in ('done', 'cancel'))
+ next_moves._do_unreserve()
+ next_moves._action_assign()
+ return mls
+
+ def write(self, vals):
+ if self.env.context.get('bypass_reservation_update'):
+ return super(StockMoveLine, self).write(vals)
+
+ if 'product_id' in vals and any(vals.get('state', ml.state) != 'draft' and vals['product_id'] != ml.product_id.id for ml in self):
+ raise UserError(_("Changing the product is only allowed in 'Draft' state."))
+
+ moves_to_recompute_state = self.env['stock.move']
+ Quant = self.env['stock.quant']
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ triggers = [
+ ('location_id', 'stock.location'),
+ ('location_dest_id', 'stock.location'),
+ ('lot_id', 'stock.production.lot'),
+ ('package_id', 'stock.quant.package'),
+ ('result_package_id', 'stock.quant.package'),
+ ('owner_id', 'res.partner')
+ ]
+ updates = {}
+ for key, model in triggers:
+ if key in vals:
+ updates[key] = self.env[model].browse(vals[key])
+
+ if 'result_package_id' in updates:
+ for ml in self.filtered(lambda ml: ml.package_level_id):
+ if updates.get('result_package_id'):
+ ml.package_level_id.package_id = updates.get('result_package_id')
+ else:
+ # TODO: make package levels less of a pain and fix this
+ package_level = ml.package_level_id
+ ml.package_level_id = False
+ package_level.unlink()
+
+ # When we try to write on a reserved move line any fields from `triggers` or directly
+ # `product_uom_qty` (the actual reserved quantity), we need to make sure the associated
+ # quants are correctly updated in order to not make them out of sync (i.e. the sum of the
+ # move lines `product_uom_qty` should always be equal to the sum of `reserved_quantity` on
+ # the quants). If the new charateristics are not available on the quants, we chose to
+ # reserve the maximum possible.
+ if updates or 'product_uom_qty' in vals:
+ for ml in self.filtered(lambda ml: ml.state in ['partially_available', 'assigned'] and ml.product_id.type == 'product'):
+
+ if 'product_uom_qty' in vals:
+ new_product_uom_qty = ml.product_uom_id._compute_quantity(
+ vals['product_uom_qty'], ml.product_id.uom_id, rounding_method='HALF-UP')
+ # Make sure `product_uom_qty` is not negative.
+ if float_compare(new_product_uom_qty, 0, precision_rounding=ml.product_id.uom_id.rounding) < 0:
+ raise UserError(_('Reserving a negative quantity is not allowed.'))
+ else:
+ new_product_uom_qty = ml.product_qty
+
+ # Unreserve the old charateristics of the move line.
+ if not ml._should_bypass_reservation(ml.location_id):
+ Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+
+ # Reserve the maximum available of the new charateristics of the move line.
+ if not ml._should_bypass_reservation(updates.get('location_id', ml.location_id)):
+ reserved_qty = 0
+ try:
+ q = Quant._update_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), new_product_uom_qty, lot_id=updates.get('lot_id', ml.lot_id),
+ package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True)
+ reserved_qty = sum([x[1] for x in q])
+ except UserError:
+ pass
+ if reserved_qty != new_product_uom_qty:
+ new_product_uom_qty = ml.product_id.uom_id._compute_quantity(reserved_qty, ml.product_uom_id, rounding_method='HALF-UP')
+ moves_to_recompute_state |= ml.move_id
+ ml.with_context(bypass_reservation_update=True).product_uom_qty = new_product_uom_qty
+
+ # When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves.
+ if updates or 'qty_done' in vals:
+ next_moves = self.env['stock.move']
+ mls = self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product')
+ if not updates: # we can skip those where qty_done is already good up to UoM rounding
+ mls = mls.filtered(lambda ml: not float_is_zero(ml.qty_done - vals['qty_done'], precision_rounding=ml.product_uom_id.rounding))
+ for ml in mls:
+ # undo the original move line
+ qty_done_orig = ml.move_id.product_uom._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
+ in_date = Quant._update_available_quantity(ml.product_id, ml.location_dest_id, -qty_done_orig, lot_id=ml.lot_id,
+ package_id=ml.result_package_id, owner_id=ml.owner_id)[1]
+ Quant._update_available_quantity(ml.product_id, ml.location_id, qty_done_orig, lot_id=ml.lot_id,
+ package_id=ml.package_id, owner_id=ml.owner_id, in_date=in_date)
+
+ # move what's been actually done
+ product_id = ml.product_id
+ location_id = updates.get('location_id', ml.location_id)
+ location_dest_id = updates.get('location_dest_id', ml.location_dest_id)
+ qty_done = vals.get('qty_done', ml.qty_done)
+ lot_id = updates.get('lot_id', ml.lot_id)
+ package_id = updates.get('package_id', ml.package_id)
+ result_package_id = updates.get('result_package_id', ml.result_package_id)
+ owner_id = updates.get('owner_id', ml.owner_id)
+ quantity = ml.move_id.product_uom._compute_quantity(qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
+ if not ml._should_bypass_reservation(location_id):
+ ml._free_reservation(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
+ if not float_is_zero(quantity, precision_digits=precision):
+ available_qty, in_date = Quant._update_available_quantity(product_id, location_id, -quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
+ if available_qty < 0 and lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(product_id, location_id, lot_id=False, package_id=package_id, owner_id=owner_id, strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(available_qty))
+ Quant._update_available_quantity(product_id, location_id, -taken_from_untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id)
+ Quant._update_available_quantity(product_id, location_id, taken_from_untracked_qty, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
+ if not ml._should_bypass_reservation(location_id):
+ ml._free_reservation(ml.product_id, location_id, untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id)
+ Quant._update_available_quantity(product_id, location_dest_id, quantity, lot_id=lot_id, package_id=result_package_id, owner_id=owner_id, in_date=in_date)
+
+ # Unreserve and reserve following move in order to have the real reserved quantity on move_line.
+ next_moves |= ml.move_id.move_dest_ids.filtered(lambda move: move.state not in ('done', 'cancel'))
+
+ # Log a note
+ if ml.picking_id:
+ ml._log_message(ml.picking_id, ml, 'stock.track_move_template', vals)
+
+ res = super(StockMoveLine, self).write(vals)
+
+ # Update scrap object linked to move_lines to the new quantity.
+ if 'qty_done' in vals:
+ for move in self.mapped('move_id'):
+ if move.scrapped:
+ move.scrap_ids.write({'scrap_qty': move.quantity_done})
+
+ # As stock_account values according to a move's `product_uom_qty`, we consider that any
+ # done stock move should have its `quantity_done` equals to its `product_uom_qty`, and
+ # this is what move's `action_done` will do. So, we replicate the behavior here.
+ if updates or 'qty_done' in vals:
+ moves = self.filtered(lambda ml: ml.move_id.state == 'done').mapped('move_id')
+ moves |= self.filtered(lambda ml: ml.move_id.state not in ('done', 'cancel') and ml.move_id.picking_id.immediate_transfer and not ml.product_uom_qty).mapped('move_id')
+ for move in moves:
+ move.product_uom_qty = move.quantity_done
+ next_moves._do_unreserve()
+ next_moves._action_assign()
+
+ if moves_to_recompute_state:
+ moves_to_recompute_state._recompute_state()
+
+ return res
+
+ def unlink(self):
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ for ml in self:
+ if ml.state in ('done', 'cancel'):
+ raise UserError(_('You can not delete product moves if the picking is done. You can only correct the done quantities.'))
+ # Unlinking a move line should unreserve.
+ if ml.product_id.type == 'product' and not ml._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.product_qty, precision_digits=precision):
+ self.env['stock.quant']._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ moves = self.mapped('move_id')
+ res = super(StockMoveLine, self).unlink()
+ if moves:
+ # Add with_prefetch() to set the _prefecht_ids = _ids
+ # because _prefecht_ids generator look lazily on the cache of move_id
+ # which is clear by the unlink of move line
+ moves.with_prefetch()._recompute_state()
+ return res
+
+ def _action_done(self):
+ """ This method is called during a move's `action_done`. It'll actually move a quant from
+ the source location to the destination location, and unreserve if needed in the source
+ location.
+
+ This method is intended to be called on all the move lines of a move. This method is not
+ intended to be called when editing a `done` move (that's what the override of `write` here
+ is done.
+ """
+ Quant = self.env['stock.quant']
+
+ # First, we loop over all the move lines to do a preliminary check: `qty_done` should not
+ # be negative and, according to the presence of a picking type or a linked inventory
+ # adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink
+ # the line. It is mandatory in order to free the reservation and correctly apply
+ # `action_done` on the next move lines.
+ ml_ids_tracked_without_lot = OrderedSet()
+ ml_ids_to_delete = OrderedSet()
+ ml_ids_to_create_lot = OrderedSet()
+ for ml in self:
+ # Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
+ uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
+ precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
+ if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
+ raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
+ defined on the unit of measure "%s". Please change the quantity done or the \
+ rounding precision of your unit of measure.') % (ml.product_id.display_name, ml.product_uom_id.name))
+
+ qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
+ if qty_done_float_compared > 0:
+ if ml.product_id.tracking != 'none':
+ picking_type_id = ml.move_id.picking_type_id
+ if picking_type_id:
+ if picking_type_id.use_create_lots:
+ # If a picking type is linked, we may have to create a production lot on
+ # the fly before assigning it to the move line if the user checked both
+ # `use_create_lots` and `use_existing_lots`.
+ if ml.lot_name and not ml.lot_id:
+ lot = self.env['stock.production.lot'].search([
+ ('company_id', '=', ml.company_id.id),
+ ('product_id', '=', ml.product_id.id),
+ ('name', '=', ml.lot_name),
+ ], limit=1)
+ if lot:
+ ml.lot_id = lot.id
+ else:
+ ml_ids_to_create_lot.add(ml.id)
+ elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots:
+ # If the user disabled both `use_create_lots` and `use_existing_lots`
+ # checkboxes on the picking type, he's allowed to enter tracked
+ # products without a `lot_id`.
+ continue
+ elif ml.move_id.inventory_id:
+ # If an inventory adjustment is linked, the user is allowed to enter
+ # tracked products without a `lot_id`.
+ continue
+
+ if not ml.lot_id and ml.id not in ml_ids_to_create_lot:
+ ml_ids_tracked_without_lot.add(ml.id)
+ elif qty_done_float_compared < 0:
+ raise UserError(_('No negative quantities allowed'))
+ else:
+ ml_ids_to_delete.add(ml.id)
+
+ if ml_ids_tracked_without_lot:
+ mls_tracked_without_lot = self.env['stock.move.line'].browse(ml_ids_tracked_without_lot)
+ raise UserError(_('You need to supply a Lot/Serial Number for product: \n - ') +
+ '\n - '.join(mls_tracked_without_lot.mapped('product_id.display_name')))
+ ml_to_create_lot = self.env['stock.move.line'].browse(ml_ids_to_create_lot)
+ ml_to_create_lot._create_and_assign_production_lot()
+
+ mls_to_delete = self.env['stock.move.line'].browse(ml_ids_to_delete)
+ mls_to_delete.unlink()
+
+ mls_todo = (self - mls_to_delete)
+ mls_todo._check_company()
+
+ # Now, we can actually move the quant.
+ ml_ids_to_ignore = OrderedSet()
+ for ml in mls_todo:
+ if ml.product_id.type == 'product':
+ rounding = ml.product_uom_id.rounding
+
+ # if this move line is force assigned, unreserve elsewhere if needed
+ if not ml._should_bypass_reservation(ml.location_id) and float_compare(ml.qty_done, ml.product_uom_qty, precision_rounding=rounding) > 0:
+ qty_done_product_uom = ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id, rounding_method='HALF-UP')
+ extra_qty = qty_done_product_uom - ml.product_qty
+ ml_to_ignore = self.env['stock.move.line'].browse(ml_ids_to_ignore)
+ ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=ml_to_ignore)
+ # unreserve what's been reserved
+ if not ml._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.product_qty:
+ Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+
+ # move what's been actually done
+ quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
+ available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ if available_qty < 0 and ml.lot_id:
+ # see if we can compensate the negative quants with some untracked quants
+ untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
+ if untracked_qty:
+ taken_from_untracked_qty = min(untracked_qty, abs(quantity))
+ Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
+ Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
+ ml_ids_to_ignore.add(ml.id)
+ # Reset the reserved quantity as we just moved it to the destination location.
+ mls_todo.with_context(bypass_reservation_update=True).write({
+ 'product_uom_qty': 0.00,
+ 'date': fields.Datetime.now(),
+ })
+
+ def _get_similar_move_lines(self):
+ self.ensure_one()
+ lines = self.env['stock.move.line']
+ picking_id = self.move_id.picking_id if self.move_id else self.picking_id
+ if picking_id:
+ lines |= picking_id.move_line_ids.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
+ return lines
+
+ def _create_and_assign_production_lot(self):
+ """ Creates and assign new production lots for move lines."""
+ lot_vals = [{
+ 'company_id': ml.move_id.company_id.id,
+ 'name': ml.lot_name,
+ 'product_id': ml.product_id.id,
+ } for ml in self]
+ lots = self.env['stock.production.lot'].create(lot_vals)
+ for ml, lot in zip(self, lots):
+ ml._assign_production_lot(lot)
+
+ def _assign_production_lot(self, lot):
+ self.ensure_one()
+ self.write({
+ 'lot_id': lot.id
+ })
+
+ def _reservation_is_updatable(self, quantity, reserved_quant):
+ self.ensure_one()
+ if (self.product_id.tracking != 'serial' and
+ self.location_id.id == reserved_quant.location_id.id and
+ self.lot_id.id == reserved_quant.lot_id.id and
+ self.package_id.id == reserved_quant.package_id.id and
+ self.owner_id.id == reserved_quant.owner_id.id):
+ return True
+ return False
+
+ def _log_message(self, record, move, template, vals):
+ data = vals.copy()
+ if 'lot_id' in vals and vals['lot_id'] != move.lot_id.id:
+ data['lot_name'] = self.env['stock.production.lot'].browse(vals.get('lot_id')).name
+ if 'location_id' in vals:
+ data['location_name'] = self.env['stock.location'].browse(vals.get('location_id')).name
+ if 'location_dest_id' in vals:
+ data['location_dest_name'] = self.env['stock.location'].browse(vals.get('location_dest_id')).name
+ if 'package_id' in vals and vals['package_id'] != move.package_id.id:
+ data['package_name'] = self.env['stock.quant.package'].browse(vals.get('package_id')).name
+ if 'package_result_id' in vals and vals['package_result_id'] != move.package_result_id.id:
+ data['result_package_name'] = self.env['stock.quant.package'].browse(vals.get('result_package_id')).name
+ if 'owner_id' in vals and vals['owner_id'] != move.owner_id.id:
+ data['owner_name'] = self.env['res.partner'].browse(vals.get('owner_id')).name
+ record.message_post_with_view(template, values={'move': move, 'vals': dict(vals, **data)}, subtype_id=self.env.ref('mail.mt_note').id)
+
+ def _free_reservation(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, ml_to_ignore=None):
+ """ When editing a done move line or validating one with some forced quantities, it is
+ possible to impact quants that were not reserved. It is therefore necessary to edit or
+ unlink the move lines that reserved a quantity now unavailable.
+
+ :param ml_to_ignore: recordset of `stock.move.line` that should NOT be unreserved
+ """
+ self.ensure_one()
+
+ if ml_to_ignore is None:
+ ml_to_ignore = self.env['stock.move.line']
+ ml_to_ignore |= self
+
+ # Check the available quantity, with the `strict` kw set to `True`. If the available
+ # quantity is greather than the quantity now unavailable, there is nothing to do.
+ available_quantity = self.env['stock.quant']._get_available_quantity(
+ product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True
+ )
+ if quantity > available_quantity:
+ # We now have to find the move lines that reserved our now unavailable quantity. We
+ # take care to exclude ourselves and the move lines were work had already been done.
+ outdated_move_lines_domain = [
+ ('state', 'not in', ['done', 'cancel']),
+ ('product_id', '=', product_id.id),
+ ('lot_id', '=', lot_id.id if lot_id else False),
+ ('location_id', '=', location_id.id),
+ ('owner_id', '=', owner_id.id if owner_id else False),
+ ('package_id', '=', package_id.id if package_id else False),
+ ('product_qty', '>', 0.0),
+ ('id', 'not in', ml_to_ignore.ids),
+ ]
+ # We take the current picking first, then the pickings with the latest scheduled date
+ current_picking_first = lambda cand: (
+ cand.picking_id != self.move_id.picking_id,
+ -(cand.picking_id.scheduled_date or cand.move_id.date).timestamp()
+ if cand.picking_id or cand.move_id
+ else -cand.id,
+ )
+ outdated_candidates = self.env['stock.move.line'].search(outdated_move_lines_domain).sorted(current_picking_first)
+
+ # As the move's state is not computed over the move lines, we'll have to manually
+ # recompute the moves which we adapted their lines.
+ move_to_recompute_state = self.env['stock.move']
+ to_unlink_candidate_ids = set()
+
+ rounding = self.product_uom_id.rounding
+ for candidate in outdated_candidates:
+ if float_compare(candidate.product_qty, quantity, precision_rounding=rounding) <= 0:
+ quantity -= candidate.product_qty
+ if candidate.qty_done:
+ move_to_recompute_state |= candidate.move_id
+ candidate.product_uom_qty = 0.0
+ else:
+ to_unlink_candidate_ids.add(candidate.id)
+ if float_is_zero(quantity, precision_rounding=rounding):
+ break
+ else:
+ # split this move line and assign the new part to our extra move
+ quantity_split = float_round(
+ candidate.product_qty - quantity,
+ precision_rounding=self.product_uom_id.rounding,
+ rounding_method='UP')
+ candidate.product_uom_qty = self.product_id.uom_id._compute_quantity(quantity_split, candidate.product_uom_id, rounding_method='HALF-UP')
+ move_to_recompute_state |= candidate.move_id
+ break
+ self.env['stock.move.line'].browse(to_unlink_candidate_ids).unlink()
+ move_to_recompute_state._recompute_state()
+
+ def _should_bypass_reservation(self, location):
+ self.ensure_one()
+ return location.should_bypass_reservation() or self.product_id.type != 'product'
+
+ def _get_aggregated_product_quantities(self, **kwargs):
+ """ Returns a dictionary of products (key = id+name+description+uom) and corresponding values of interest.
+
+ Allows aggregation of data across separate move lines for the same product. This is expected to be useful
+ in things such as delivery reports. Dict key is made as a combination of values we expect to want to group
+ the products by (i.e. so data is not lost). This function purposely ignores lots/SNs because these are
+ expected to already be properly grouped by line.
+
+ returns: dictionary {product_id+name+description+uom: {product, name, description, qty_done, product_uom}, ...}
+ """
+ aggregated_move_lines = {}
+ for move_line in self:
+ name = move_line.product_id.display_name
+ description = move_line.move_id.description_picking
+ if description == name or description == move_line.product_id.name:
+ description = False
+ uom = move_line.product_uom_id
+ line_key = str(move_line.product_id.id) + "_" + name + (description or "") + "uom " + str(uom.id)
+
+ if line_key not in aggregated_move_lines:
+ aggregated_move_lines[line_key] = {'name': name,
+ 'description': description,
+ 'qty_done': move_line.qty_done,
+ 'product_uom': uom.name,
+ 'product': move_line.product_id}
+ else:
+ aggregated_move_lines[line_key]['qty_done'] += move_line.qty_done
+ return aggregated_move_lines