From 5ef46664a25923e6f9e632fd10e031d4a2e81c26 Mon Sep 17 00:00:00 2001 From: William Henrotin Date: Fri, 21 Dec 2018 13:11:31 +0000 Subject: [PATCH] [IMP] mrp: add production lines To record a production via a manufacturing order, the user can either use the produce wizard or the workorders views. Those two objects was technically different but act more or less the same. This commit aims to merge the similar behaviors in common code We introduce two new abstract models : 1. Abstract workorder to share workorders and the produce wizard 2. Abstract workorder lines to share active_move_line on workorder and product_produce_line on the wizard. Those abstract line keep the information about the quantities and lot number to put on component move lines and finished product move lines Task : 1891864 --- addons/mrp/data/mrp_demo.xml | 11 +- addons/mrp/models/__init__.py | 1 + addons/mrp/models/mrp_abstract_workorder.py | 354 ++++++++++++++++++ addons/mrp/models/mrp_production.py | 4 +- addons/mrp/models/mrp_workorder.py | 228 ++--------- addons/mrp/models/stock_move.py | 59 --- addons/mrp/security/ir.model.access.csv | 2 + addons/mrp/tests/test_order.py | 46 +-- addons/mrp/tests/test_procurement.py | 4 +- addons/mrp/tests/test_traceability.py | 5 +- addons/mrp/tests/test_unbuild.py | 46 +-- .../test_warehouse_multistep_manufacturing.py | 2 +- addons/mrp/tests/test_workorder_operation.py | 44 +-- addons/mrp/views/mrp_workorder_views.xml | 11 +- addons/mrp/wizard/change_production_qty.py | 11 +- addons/mrp/wizard/mrp_product_produce.py | 206 +++------- .../mrp/wizard/mrp_product_produce_views.xml | 14 +- addons/mrp_byproduct/models/mrp_production.py | 8 +- .../mrp_byproduct/tests/test_mrp_byproduct.py | 2 +- addons/sale_mrp/tests/test_sale_mrp_flow.py | 2 +- 20 files changed, 554 insertions(+), 506 deletions(-) create mode 100644 addons/mrp/models/mrp_abstract_workorder.py diff --git a/addons/mrp/data/mrp_demo.xml b/addons/mrp/data/mrp_demo.xml index 68e28abe950c5..c168e4902eaed 100644 --- a/addons/mrp/data/mrp_demo.xml +++ b/addons/mrp/data/mrp_demo.xml @@ -696,14 +696,17 @@ - - + + diff --git a/addons/mrp/models/__init__.py b/addons/mrp/models/__init__.py index e9fc93bef6ae6..db69451b2fece 100644 --- a/addons/mrp/models/__init__.py +++ b/addons/mrp/models/__init__.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import mrp_document +from . import mrp_abstract_workorder from . import res_config_settings from . import mrp_bom from . import mrp_routing diff --git a/addons/mrp/models/mrp_abstract_workorder.py b/addons/mrp/models/mrp_abstract_workorder.py new file mode 100644 index 0000000000000..44fb34fdf1ee9 --- /dev/null +++ b/addons/mrp/models/mrp_abstract_workorder.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.addons import decimal_precision as dp +from odoo.exceptions import UserError +from odoo.tools import float_compare, float_round, float_is_zero + + +class MrpAbstractWorkorder(models.AbstractModel): + _name = "mrp.abstract.workorder" + _description = "Common code between produce wizards and workorders." + + production_id = fields.Many2one('mrp.production', 'Manufacturing Order', required=True) + product_id = fields.Many2one(related='production_id.product_id', readonly=True, store=True) + qty_producing = fields.Float(string='Currently Produced Quantity', digits=dp.get_precision('Product Unit of Measure')) + product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True, readonly=True) + final_lot_id = fields.Many2one('stock.production.lot', string='Lot/Serial Number', domain="[('product_id', '=', product_id)]") + product_tracking = fields.Selection(related="product_id.tracking") + + @api.onchange('qty_producing') + def _onchange_qty_producing(self): + line_values = self._update_workorder_lines() + for vals in line_values['to_create']: + self.workorder_line_ids |= self.workorder_line_ids.new(vals) + if line_values['to_delete']: + self.workorder_line_ids -= line_values['to_delete'] + for line, vals in line_values['to_update'].items(): + line.update(vals) + + def _update_workorder_lines(self): + """ Update workorder lines, according to the new qty currently + produced. It returns a dict with line to create, update or delete. + It do not directly write or unlink the line because this function is + used in onchange and request that write on db (e.g. workorder creation). + """ + line_values = {'to_create': [], 'to_delete': [], 'to_update': {}} + for move_raw in self.move_raw_ids.filtered(lambda move: move.state not in ('done', 'cancel')): + move_workorder_lines = self.workorder_line_ids.filtered(lambda w: w.move_id == move_raw) + + # Compute the new quantity for the current component + rounding = move_raw.product_uom.rounding + new_qty = self.product_uom_id._compute_quantity( + self.qty_producing * move_raw.unit_factor, + self.production_id.product_uom_id, + round=False + ) + qty_todo = float_round(new_qty - sum(move_workorder_lines.mapped('qty_done')), precision_rounding=rounding) + + # Remove or lower quantity on exisiting workorder lines + if float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0: + qty_todo = abs(qty_todo) + for workorder_line in move_workorder_lines: + if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: + break + if float_compare(workorder_line.qty_to_consume, qty_todo, precision_rounding=rounding) <= 0: + # update qty_todo for next wo_line + qty_todo = float_round(qty_todo - workorder_line.qty_to_consume, precision_rounding=rounding) + if line_values['to_delete']: + line_values['to_delete'] |= workorder_line + else: + line_values['to_delete'] = workorder_line + else: + new_val = workorder_line.qty_to_consume - qty_todo + line_values['to_update'][workorder_line] = { + 'qty_to_consume': new_val, + 'qty_done': new_val, + 'qty_reserved': new_val, + } + qty_todo = 0 + else: + # Search among wo lines which one could be updated + for move_line in move_raw.move_line_ids: + # Get workorder lines that match reservation. + candidates = move_workorder_lines._find_candidate(move_line) + while candidates: + if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: + break + candidate = candidates.pop() + qty_to_add = move_line.product_uom_qty - candidate.qty_done + line_values['to_update'][candidate] = { + 'qty_done': candidate.qty_done + qty_to_add, + 'qty_to_consume': candidate.qty_to_consume + qty_to_add, + 'qty_reserved': candidate.qty_reserved + qty_to_add, + } + qty_todo -= qty_to_add + + # if there are still qty_todo, create new wo lines + if float_compare(qty_todo, 0.0, precision_rounding=rounding) > 0: + for vals in self._generate_lines_values(move_raw, qty_todo): + line_values['to_create'].append(vals) + return line_values + + @api.model + def _generate_lines_values(self, move, qty_to_consume): + """ Create workorder line. First generate line based on the reservation, + in order to match the reservation. If the quantity to consume is greater + than the reservation quantity then create line with the correct quantity + to consume but without lot or serial number. + """ + lines = [] + is_tracked = move.product_id.tracking != 'none' + for move_line in move.move_line_ids: + if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) <= 0: + break + # move line already 'used' in workorder (from its lot for instance) + if move_line.lot_produced_id or float_compare(move_line.product_uom_qty, move_line.qty_done, precision_rounding=move.product_uom.rounding) <= 0: + continue + # search wo line on which the lot is not fully consumed or other reserved lot + linked_wo_line = self.workorder_line_ids.filtered( + lambda line: line.product_id == move_line.product_id and + line.lot_id == move_line.lot_id + ) + if linked_wo_line: + if float_compare(sum(linked_wo_line.mapped('qty_to_consume')), move_line.product_uom_qty, precision_rounding=move.product_uom.rounding) < 0: + to_consume_in_line = min(qty_to_consume, move_line.product_uom_qty - sum(linked_wo_line.mapped('qty_to_consume'))) + else: + continue + else: + to_consume_in_line = min(qty_to_consume, move_line.product_uom_qty) + line = { + 'move_id': move.id, + 'product_id': move.product_id.id, + 'product_uom_id': is_tracked and move.product_id.uom_id.id or move.product_uom.id, + 'qty_to_consume': to_consume_in_line, + 'qty_reserved': move_line.product_uom_qty, + 'lot_id': move_line.lot_id.id, + 'qty_done': is_tracked and 0 or to_consume_in_line + } + lines.append(line) + qty_to_consume -= to_consume_in_line + # The move has not reserved the whole quantity so we create new wo lines + if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: + if move.product_id.tracking == 'serial': + while float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: + line = { + 'move_id': move.id, + 'product_id': move.product_id.id, + 'product_uom_id': move.product_id.uom_id.id, + 'qty_to_consume': 1, + 'qty_done': 1, + } + lines.append(line) + qty_to_consume -= 1 + else: + line = { + 'move_id': move.id, + 'product_id': move.product_id.id, + 'product_uom_id': move.product_uom.id, + 'qty_to_consume': qty_to_consume, + 'qty_done': is_tracked and 0 or qty_to_consume + } + lines.append(line) + return lines + + def _update_finished_move(self): + """ Update the finished move & move lines in order to set the finished + product lot on it as well as the produced quantity. This method get the + information either from the last workorder or from the Produce wizard.""" + production_move = self.production_id.move_finished_ids.filtered( + lambda move: move.product_id == self.product_id and + move.state not in ('done', 'cancel') + ) + if production_move and production_move.product_id.tracking != 'none': + if not self.final_lot_id: + raise UserError(_('You need to provide a lot for the finished product.')) + move_line = production_move.move_line_ids.filtered( + lambda line: line.lot_id.id == self.final_lot_id.id + ) + if move_line: + if self.product_id.tracking == 'serial': + raise UserError(_('You cannot produce the same serial number twice.')) + move_line.product_uom_qty += self.qty_producing + move_line.qty_done += self.qty_producing + else: + move_line.create({ + 'move_id': production_move.id, + 'product_id': production_move.product_id.id, + 'lot_id': self.final_lot_id.id, + 'product_uom_qty': self.qty_producing, + 'product_uom_id': self.product_uom_id.id, + 'qty_done': self.qty_producing, + 'location_id': production_move.location_id.id, + 'location_dest_id': production_move.location_dest_id.id, + }) + else: + rounding = production_move.product_uom.rounding + production_move._set_quantity_done( + production_move.quantity_done + float_round(self.qty_producing, precision_rounding=rounding) + ) + + def _update_raw_moves(self): + """ Once the production is done, the lots written on workorder lines + are saved on stock move lines""" + vals_list = [] + workorder_lines_to_process = self.workorder_line_ids.filtered(lambda line: line.qty_done > 0) + for line in workorder_lines_to_process: + line._update_move_lines() + if float_compare(line.qty_done, 0, precision_rounding=line.product_uom_id.rounding) > 0: + vals_list += line._create_extra_move_lines() + + self.workorder_line_ids.unlink() + self.env['stock.move.line'].create(vals_list) + + +class MrpAbstractWorkorderLine(models.AbstractModel): + _name = "mrp.abstract.workorder.line" + _description = "Abstract model to implement product_produce_line as well as\ + workorder_line" + + move_id = fields.Many2one('stock.move') + product_id = fields.Many2one('product.product', 'Product', required=True) + product_tracking = fields.Selection(related="product_id.tracking") + lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial Number') + qty_to_consume = fields.Float('To Consume', digits=dp.get_precision('Product Unit of Measure')) + product_uom_id = fields.Many2one('uom.uom', string='Unit of Measure') + qty_done = fields.Float('Consumed') + qty_reserved = fields.Float('Reserved', digits=dp.get_precision('Product Unit of Measure')) + + @api.onchange('lot_id') + def _onchange_lot_id(self): + """ When the user is encoding a produce line for a tracked product, we apply some logic to + help him. This onchange will automatically switch `qty_done` to 1.0. + """ + if self.product_id.tracking == 'serial': + self.qty_done = 1 + + @api.onchange('qty_done') + def _onchange_qty_done(self): + """ When the user is encoding a produce 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.product_id.tracking == 'serial' and not float_is_zero(self.qty_done, self.product_uom_id.rounding): + if float_compare(self.qty_done, 1.0, precision_rounding=self.product_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 _update_move_lines(self): + """ update a move line to save the workorder line data""" + self.ensure_one() + if self.lot_id: + move_lines = self.move_id.move_line_ids.filtered(lambda ml: ml.lot_id == self.lot_id and not ml.lot_produced_id) + else: + move_lines = self.move_id.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id) + + # Sanity check: if the product is a serial number and `lot` is already present in the other + # consumed move lines, raise. + if self.product_id.tracking != 'none' and not self.lot_id: + raise UserError(_('Please enter a lot or serial number for %s !' % self.product_id.display_name)) + + if self.lot_id and self.product_id.tracking == 'serial' and self.lot_id in self.move_id.move_line_ids.filtered(lambda ml: ml.qty_done).mapped('lot_id'): + raise UserError(_('You cannot consume the same serial number twice. Please correct the serial numbers encoded.')) + + # Update reservation and quantity done + for ml in move_lines: + rounding = ml.product_uom_id.rounding + if float_compare(self.qty_done, 0, precision_rounding=rounding) <= 0: + break + quantity_to_process = min(self.qty_done, ml.product_uom_qty - ml.qty_done) + self.qty_done -= quantity_to_process + + new_quantity_done = (ml.qty_done + quantity_to_process) + # if we produce less than the reserved quantity to produce the finished products + # in different lots, + # we create different component_move_lines to record which one was used + # on which lot of finished product + if float_compare(new_quantity_done, ml.product_uom_qty, precision_rounding=rounding) >= 0: + ml.write({ + 'qty_done': new_quantity_done, + 'lot_produced_id': self._get_final_lot().id + }) + else: + new_qty_reserved = ml.product_uom_qty - new_quantity_done + default = { + 'product_uom_qty': new_quantity_done, + 'qty_done': new_quantity_done, + 'lot_produced_id': self._get_final_lot().id + } + ml.copy(default=default) + ml.with_context(bypass_reservation_update=True).write({ + 'product_uom_qty': new_qty_reserved, + 'qty_done': 0 + }) + + def _create_extra_move_lines(self): + """Create new sml if quantity produced is bigger than the reserved one""" + vals_list = [] + quants = self.env['stock.quant']._gather(self.product_id, self.move_id.location_id, lot_id=self.lot_id, strict=False) + # Search for a sub-locations where the product is available. + # Loop on the quants to get the locations. If there is not enough + # quantity into stock, we take the move location. Anyway, no + # reservation is made, so it is still possible to change it afterwards. + for quant in quants: + quantity = quant.reserved_quantity - quant.quantity + rounding = quant.product_uom_id.rounding + if (float_compare(quant.quantity, 0, precision_rounding=rounding) <= 0 or + float_compare(quantity, 0, precision_rounding=rounding) <= 0): + continue + vals = { + 'move_id': self.move_id.id, + 'product_id': self.product_id.id, + 'location_id': quant.location_id.id, + 'location_dest_id': self.move_id.location_dest_id.id, + 'product_uom_qty': 0, + 'product_uom_id': quant.product_uom_id.id, + 'qty_done': min(quantity, self.qty_done), + 'lot_produced_id': self._get_final_lot().id, + } + if self.lot_id: + vals.update({'lot_id': self.lot_id.id}) + + vals_list.append(vals) + self.qty_done -= vals['qty_done'] + # If all the qty_done is distributed, we can close the loop + if float_compare(self.qty_done, 0, precision_rounding=self.product_uom_id.rounding) <= 0: + break + + if float_compare(self.qty_done, 0, precision_rounding=self.product_uom_id.rounding) > 0: + vals = { + 'move_id': self.move_id.id, + 'product_id': self.product_id.id, + 'location_id': self.move_id.location_id.id, + 'location_dest_id': self.move_id.location_dest_id.id, + 'product_uom_qty': 0, + 'product_uom_id': self.product_uom_id.id, + 'qty_done': self.qty_done, + 'lot_produced_id': self._get_final_lot().id, + } + if self.lot_id: + vals.update({'lot_id': self.lot_id.id}) + + vals_list.append(vals) + + return vals_list + + def _find_candidate(self, move_line): + """ Method used in order to return move lines that match reservation. + The purpose is to update exisiting workorder line regarding the + reservation of the raw moves. + """ + rounding = move_line.product_uom_id.rounding + return self.filtered(lambda line: + line.lot_id == move_line.lot_id and + float_compare(line.qty_done, move_line.product_uom_qty, precision_rounding=rounding) < 0 and + line.product_id == move_line.product_id) + + # To be implemented in specific model + def _get_final_lot(self): + raise NotImplementedError('Method _get_final_lot() undefined on %s' % self) + + def _get_production(self): + raise NotImplementedError('Method _get_production() undefined on %s' % self) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 4adb325c4d371..b497dca8e6af5 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -509,6 +509,7 @@ def _get_move_raw_values(self, bom_line, line_data): data = { 'sequence': bom_line.sequence, 'name': self.name, + 'reference': self.name, 'date': self.date_planned_start, 'date_expected': self.date_planned_start, 'bom_line_id': bom_line.id, @@ -715,6 +716,7 @@ def _workorders_create(self, bom, bom_data): 'name': operation.name, 'production_id': self.id, 'workcenter_id': operation.workcenter_id.id, + 'product_uom_id': self.product_uom_id.id, 'operation_id': operation.id, 'duration_expected': duration_expected, 'state': len(workorders) == 0 and 'ready' or 'pending', @@ -733,7 +735,7 @@ def _workorders_create(self, bom, bom_data): moves_raw.mapped('move_line_ids').write({'workorder_id': workorder.id}) (moves_finished + moves_raw).write({'workorder_id': workorder.id}) - workorder._generate_lot_ids() + workorder.generate_wo_lines() return workorders def _check_lots(self): diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index cc1be6ab7a83f..a892db6e098ad 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -13,31 +13,17 @@ class MrpWorkorder(models.Model): _name = 'mrp.workorder' _description = 'Work Order' - _inherit = ['mail.thread', 'mail.activity.mixin'] + _inherit = ['mail.thread', 'mail.activity.mixin', 'mrp.abstract.workorder'] name = fields.Char( 'Work Order', required=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) - workcenter_id = fields.Many2one( 'mrp.workcenter', 'Work Center', required=True, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) working_state = fields.Selection( 'Workcenter Status', related='workcenter_id.working_state', readonly=False, help='Technical: used in views only') - - production_id = fields.Many2one( - 'mrp.production', 'Manufacturing Order', - index=True, ondelete='cascade', required=True, tracking=True, - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) - product_id = fields.Many2one( - 'product.product', 'Product', - related='production_id.product_id', readonly=True, - help='Technical: used in views only.', store=True) - product_uom_id = fields.Many2one( - 'uom.uom', 'Unit of Measure', - related='production_id.product_uom_id', readonly=True, - help='Technical: used in views only.') production_availability = fields.Selection( 'Stock Availability', readonly=True, related='production_id.reservation_state', store=True, @@ -46,9 +32,6 @@ class MrpWorkorder(models.Model): 'Production State', readonly=True, related='production_id.state', help='Technical: used in views only.') - product_tracking = fields.Selection( - 'Product Tracking', related='production_id.product_id.tracking', readonly=False, - help='Technical: used in views only.') qty_production = fields.Float('Original Production Quantity', readonly=True, related='production_id.product_qty') qty_remaining = fields.Float('Quantity To Be Produced', compute='_compute_qty_remaining', digits=dp.get_precision('Product Unit of Measure')) qty_produced = fields.Float( @@ -56,13 +39,8 @@ class MrpWorkorder(models.Model): readonly=True, digits=dp.get_precision('Product Unit of Measure'), help="The number of products already handled by this work order") - qty_producing = fields.Float( - 'Currently Produced Quantity', default=1.0, - digits=dp.get_precision('Product Unit of Measure'), - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) is_produced = fields.Boolean(string="Has Been Produced", compute='_compute_is_produced') - state = fields.Selection([ ('pending', 'Waiting for another WO'), ('ready', 'Ready'), @@ -105,11 +83,7 @@ class MrpWorkorder(models.Model): 'stock.move', 'workorder_id', 'Moves') move_line_ids = fields.One2many( 'stock.move.line', 'workorder_id', 'Moves to Track', - domain=[('done_wo', '=', True)], help="Inventory moves for which you must scan a lot number at this work order") - active_move_line_ids = fields.One2many( - 'stock.move.line', 'workorder_id', - domain=[('done_wo', '=', False)]) final_lot_id = fields.Many2one( 'stock.production.lot', 'Lot/Serial Number', domain="[('product_id', '=', product_id)]", states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) @@ -129,6 +103,7 @@ class MrpWorkorder(models.Model): capacity = fields.Float( 'Capacity', default=1.0, help="Number of pieces that can be produced in parallel.") + workorder_line_ids = fields.One2many('mrp.workorder.line', 'workorder_id', string='Workorder lines') @api.multi def name_get(self): @@ -179,55 +154,6 @@ def _compute_color(self): for order in (self - late_orders): order.color = 2 - @api.onchange('qty_producing') - def _onchange_qty_producing(self): - """ Update stock.move.lot records, according to the new qty currently - produced. """ - moves = self.move_raw_ids.filtered(lambda move: move.state not in ('done', 'cancel') and move.product_id.tracking != 'none' and move.product_id.id != self.production_id.product_id.id) - for move in moves: - move_lots = self.active_move_line_ids.filtered(lambda move_lot: move_lot.move_id == move) - if not move_lots: - continue - rounding = move.product_uom.rounding - new_qty = float_round(move.unit_factor * self.qty_producing, precision_rounding=rounding) - if move.product_id.tracking == 'lot': - move_lots[0].product_qty = new_qty - move_lots[0].qty_done = new_qty - elif move.product_id.tracking == 'serial': - # Create extra pseudo record - qty_todo = float_round(new_qty - sum(move_lots.mapped('qty_done')), precision_rounding=rounding) - if float_compare(qty_todo, 0.0, precision_rounding=rounding) > 0: - while float_compare(qty_todo, 0.0, precision_rounding=rounding) > 0: - self.active_move_line_ids += self.env['stock.move.line'].new({ - 'move_id': move.id, - 'product_id': move.product_id.id, - 'lot_id': False, - 'product_uom_qty': 0.0, - 'product_uom_id': move.product_uom.id, - 'qty_done': min(1.0, qty_todo), - 'workorder_id': self.id, - 'done_wo': False, - 'location_id': move.location_id.id, - 'location_dest_id': move.location_dest_id.id, - 'date': move.date, - }) - qty_todo -= 1 - elif float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0: - qty_todo = abs(qty_todo) - for move_lot in move_lots: - if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: - break - if not move_lot.lot_id and float_compare(qty_todo, move_lot.qty_done, precision_rounding=rounding) >= 0: - qty_todo = float_round(qty_todo - move_lot.qty_done, precision_rounding=rounding) - self.active_move_line_ids -= move_lot # Difference operator - else: - #move_lot.product_qty = move_lot.product_qty - qty_todo - if float_compare(move_lot.qty_done - qty_todo, 0, precision_rounding=rounding) == 1: - move_lot.qty_done = move_lot.qty_done - qty_todo - else: - move_lot.qty_done = 0 - qty_todo = 0 - @api.multi def write(self, values): if list(values.keys()) != ['time_ids'] and any(workorder.state == 'done' for workorder in self): @@ -240,42 +166,20 @@ def write(self, values): raise UserError(_('The planned end date of the work order cannot be prior to the planned start date, please correct this to save the work order.')) return super(MrpWorkorder, self).write(values) - def _generate_lot_ids(self): - """ Generate stock move lines """ + def generate_wo_lines(self): + """ Generate workorder line """ self.ensure_one() - MoveLine = self.env['stock.move.line'] - tracked_moves = self.move_raw_ids.filtered( - lambda move: move.state not in ('done', 'cancel') and move.product_id.tracking != 'none' and move.product_id != self.production_id.product_id) - for move in tracked_moves: - qty = move.unit_factor * self.qty_producing - if move.product_id.tracking == 'serial': - while float_compare(qty, 0.0, precision_rounding=move.product_uom.rounding) > 0: - MoveLine.create({ - 'move_id': move.id, - 'product_uom_qty': 0, - 'product_uom_id': move.product_uom.id, - 'qty_done': min(1, qty), - 'production_id': self.production_id.id, - 'workorder_id': self.id, - 'product_id': move.product_id.id, - 'done_wo': False, - 'location_id': move.location_id.id, - 'location_dest_id': move.location_dest_id.id, - }) - qty -= 1 - else: - MoveLine.create({ - 'move_id': move.id, - 'product_uom_qty': 0, - 'product_uom_id': move.product_uom.id, - 'qty_done': qty, - 'product_id': move.product_id.id, - 'production_id': self.production_id.id, - 'workorder_id': self.id, - 'done_wo': False, - 'location_id': move.location_id.id, - 'location_dest_id': move.location_dest_id.id, - }) + raw_moves = self.move_raw_ids.filtered( + lambda move: move.state not in ('done', 'cancel') + ) + for move in raw_moves: + qty_to_consume = move.product_uom._compute_quantity( + self.qty_producing * move.unit_factor, + move.product_id.uom_id, + round=False + ) + line_values = self._generate_lines_values(move, qty_to_consume) + self.workorder_line_ids |= self.env['mrp.workorder.line'].create(line_values) def _assign_default_final_lot_id(self): self.final_lot_id = self.env['stock.production.lot'].search([('use_next_on_work_order_id', '=', self.id)], @@ -288,7 +192,6 @@ def _get_byproduct_move_line(self, by_product_move, quantity): 'product_uom_qty': quantity, 'product_uom_id': by_product_move.product_uom.id, 'qty_done': quantity, - 'workorder_id': self.id, 'location_id': by_product_move.location_id.id, 'location_dest_id': by_product_move.location_dest_id.id, } @@ -305,89 +208,22 @@ def record_production(self): if self.qty_producing <= 0: raise UserError(_('Please set the quantity you are currently producing. It should be different from zero.')) - if (self.production_id.product_id.tracking != 'none') and not self.final_lot_id and self.move_raw_ids: - raise UserError(_('You should provide a lot/serial number for the final product.')) - - # Update quantities done on each raw material line - # For each untracked component without any 'temporary' move lines, - # (the new workorder tablet view allows registering consumed quantities for untracked components) - # we assume that only the theoretical quantity was used - for move in self.move_raw_ids: - if move.has_tracking == 'none' and (move.state not in ('done', 'cancel'))\ - and move.unit_factor and not move.move_line_ids.filtered(lambda ml: not ml.done_wo): - rounding = move.product_uom.rounding - if self.product_id.tracking != 'none': - qty_to_add = float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding) - move._generate_consumed_move_line(qty_to_add, self.final_lot_id) - elif len(move._get_move_lines()) < 2: - move.quantity_done += float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding) - else: - move._set_quantity_done(move.quantity_done + float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding)) - - # Transfer quantities from temporary to final move lots or make them final - for move_line in self.active_move_line_ids: - # Check if move_line already exists - if move_line.qty_done <= 0: # rounding... - move_line.sudo().unlink() - continue - if move_line.product_id.tracking != 'none' and not move_line.lot_id: - raise UserError(_('You should provide a lot/serial number for a component.')) - # Search other move_line where it could be added: - lots = self.move_line_ids.filtered(lambda x: (x.lot_id.id == move_line.lot_id.id) and (not x.lot_produced_id) and (not x.done_move) and (x.product_id == move_line.product_id)) - if lots: - lots[0].qty_done += move_line.qty_done - lots[0].lot_produced_id = self.final_lot_id.id - self._link_to_quality_check(move_line, lots[0]) - move_line.sudo().unlink() - else: - move_line.lot_produced_id = self.final_lot_id.id - move_line.done_wo = True - # One a piece is produced, you can launch the next work order if self.next_work_order_id.state == 'pending': self.next_work_order_id.state = 'ready' - self.move_line_ids.filtered( - lambda move_line: not move_line.done_move and not move_line.lot_produced_id and move_line.qty_done > 0 - ).write({ - 'lot_produced_id': self.final_lot_id.id, - 'lot_produced_qty': self.qty_producing - }) - # If last work order, then post lots used # TODO: should be same as checking if for every workorder something has been done? if not self.next_work_order_id: - production_move = self.production_id.move_finished_ids.filtered( - lambda x: (x.product_id.id == self.production_id.product_id.id) and (x.state not in ('done', 'cancel'))) - if production_move.product_id.tracking != 'none': - move_line = production_move.move_line_ids.filtered(lambda x: x.lot_id.id == self.final_lot_id.id) - if move_line: - move_line.product_uom_qty += self.qty_producing - move_line.qty_done += self.qty_producing - else: - move_line.create({'move_id': production_move.id, - 'product_id': production_move.product_id.id, - 'lot_id': self.final_lot_id.id, - 'product_uom_qty': self.qty_producing, - 'product_uom_id': production_move.product_uom.id, - 'qty_done': self.qty_producing, - 'workorder_id': self.id, - 'location_id': production_move.location_id.id, - 'location_dest_id': production_move.location_dest_id.id, - }) - else: - production_move.quantity_done += self.qty_producing + uom_id = self.production_id.product_uom_id + self._update_finished_move() + self.production_id.move_finished_ids.filtered( + lambda move: move.product_id == self.product_id and + move.state not in ('done', 'cancel') + ).workorder_id = self.id - if not self.next_work_order_id: - for by_product_move in self.production_id.move_finished_ids.filtered(lambda x: (x.product_id.id != self.production_id.product_id.id) and (x.state not in ('done', 'cancel'))): - if by_product_move.has_tracking != 'serial': - values = self._get_byproduct_move_line(by_product_move, self.qty_producing * by_product_move.unit_factor) - self.env['stock.move.line'].create(values) - elif by_product_move.has_tracking == 'serial': - qty_todo = by_product_move.product_uom._compute_quantity(self.qty_producing * by_product_move.unit_factor, by_product_move.product_id.uom_id) - for i in range(0, int(float_round(qty_todo, precision_digits=0))): - values = self._get_byproduct_move_line(by_product_move, 1) - self.env['stock.move.line'].create(values) + # Transfer quantities from temporary to final move line or make them final + self._update_raw_moves() # Update workorder quantity produced self.qty_produced += self.qty_producing @@ -403,10 +239,10 @@ def record_production(self): elif self.production_id.product_id.tracking == 'serial': self._assign_default_final_lot_id() self.qty_producing = 1.0 - self._generate_lot_ids() + self.generate_wo_lines() else: self.qty_producing = float_round(self.production_id.product_qty - self.qty_produced, precision_rounding=rounding) - self._generate_lot_ids() + self.generate_wo_lines() if self.next_work_order_id and self.production_id.product_id.tracking != 'none': self.next_work_order_id._assign_default_final_lot_id() @@ -541,3 +377,17 @@ def action_see_move_scrap(self): def _compute_qty_remaining(self): for wo in self: wo.qty_remaining = float_round(wo.qty_production - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id.rounding) + + +class MrpWorkorderLine(models.Model): + _name = 'mrp.workorder.line' + _inherit = ["mrp.abstract.workorder.line"] + _description = "Workorder move line" + + workorder_id = fields.Many2one('mrp.workorder', 'Workorder') + + def _get_final_lot(self): + return self.workorder_id.final_lot_id + + def _get_production(self): + return self.workorder_id.production_id diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 7d079afe6fd55..34c3269e7c76f 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -225,65 +225,6 @@ def _generate_move_phantom(self, bom_line, product_qty, quantity_done): return move return self.env['stock.move'] - def _generate_consumed_move_line(self, qty_to_add, final_lot, lot=False): - if lot: - move_lines = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id) - else: - move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id) - - # Sanity check: if the product is a serial number and `lot` is already present in the other - # consumed move lines, raise. - if lot and self.product_id.tracking == 'serial' and lot in self.move_line_ids.filtered(lambda ml: ml.qty_done).mapped('lot_id'): - raise UserError(_('You cannot consume the same serial number twice. Please correct the serial numbers encoded.')) - - for ml in move_lines: - rounding = ml.product_uom_id.rounding - if float_compare(qty_to_add, 0, precision_rounding=rounding) <= 0: - break - quantity_to_process = min(qty_to_add, ml.product_uom_qty - ml.qty_done) - qty_to_add -= quantity_to_process - - new_quantity_done = (ml.qty_done + quantity_to_process) - if float_compare(new_quantity_done, ml.product_uom_qty, precision_rounding=rounding) >= 0: - ml.write({'qty_done': new_quantity_done, 'lot_produced_id': final_lot.id}) - else: - new_qty_reserved = ml.product_uom_qty - new_quantity_done - default = {'product_uom_qty': new_quantity_done, - 'qty_done': new_quantity_done, - 'lot_produced_id': final_lot.id} - ml.copy(default=default) - ml.with_context(bypass_reservation_update=True).write({'product_uom_qty': new_qty_reserved, 'qty_done': 0}) - - if float_compare(qty_to_add, 0, precision_rounding=self.product_uom.rounding) > 0: - # Search for a sub-location where the product is available. This might not be perfectly - # correct if the quantity available is spread in several sub-locations, but at least - # we should be closer to the reality. Anyway, no reservation is made, so it is still - # possible to change it afterwards. - quants = self.env['stock.quant']._gather(self.product_id, self.location_id, lot_id=lot, strict=False) - available_quantity = self.product_id.uom_id._compute_quantity( - self.env['stock.quant']._get_available_quantity( - self.product_id, self.location_id, lot_id=lot, strict=False - ), self.product_uom - ) - location_id = False - if float_compare(qty_to_add, available_quantity, precision_rounding=self.product_uom.rounding) < 0: - location_id = quants.filtered(lambda r: r.quantity > 0)[-1:].location_id - - vals = { - 'move_id': self.id, - 'product_id': self.product_id.id, - 'location_id': location_id.id if location_id else self.location_id.id, - 'production_id': self.raw_material_production_id.id, - 'location_dest_id': self.location_dest_id.id, - 'product_uom_qty': 0, - 'product_uom_id': self.product_uom.id, - 'qty_done': qty_to_add, - 'lot_produced_id': final_lot.id, - } - if lot: - vals.update({'lot_id': lot.id}) - self.env['stock.move.line'].create(vals) - def _get_upstream_documents_and_responsibles(self, visited): if self.created_production_id and self.created_production_id.state not in ('done', 'cancel'): return [(self.created_production_id, self.created_production_id.user_id, visited)] diff --git a/addons/mrp/security/ir.model.access.csv b/addons/mrp/security/ir.model.access.csv index 0f48edfa18d13..23b010a67b8c4 100644 --- a/addons/mrp/security/ir.model.access.csv +++ b/addons/mrp/security/ir.model.access.csv @@ -28,6 +28,8 @@ access_product_supplierinfo_user,product.supplierinfo user,product.model_product access_res_partner,res.partner,base.model_res_partner,mrp.group_mrp_user,1,0,0,0 access_mrp_workorder_mrp_user,mrp.workorder.user,model_mrp_workorder,mrp.group_mrp_user,1,1,1,1 access_mrp_workorder_mrp_manager,mrp.workorder,model_mrp_workorder,mrp.group_mrp_manager,1,1,1,1 +access_mrp_workorder_line_mrp_user,mrp.workorder.user,model_mrp_workorder_line,mrp.group_mrp_user,1,1,1,1 +access_mrp_workorder_line_mrp_manager,mrp.workorder,model_mrp_workorder_line,mrp.group_mrp_manager,1,1,1,1 access_resource_calendar_leaves_user,mrp.resource.calendar.leaves.user,resource.model_resource_calendar_leaves,mrp.group_mrp_user,1,1,1,1 access_resource_calendar_leaves_manager,mrp.resource.calendar.leaves.manager,resource.model_resource_calendar_leaves,mrp.group_mrp_manager,1,0,0,0 access_resource_calendar_attendance_mrp_user,mrp.resource.calendar.attendance.mrp.user,resource.model_resource_calendar_attendance,mrp.group_mrp_user,1,1,1,1 diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index ac4ec19b748a1..2935b6b47f2b5 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -143,7 +143,7 @@ def test_basic(self): 'active_id': man_order.id, 'active_ids': [man_order.id], })) - produce_form.product_qty = 1.0 + produce_form.qty_producing = 1.0 produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -412,7 +412,7 @@ def test_multiple_post_inventory(self): # produce one item, call `post_inventory` context = {"active_ids": [mo_custom_laptop.id], "active_id": mo_custom_laptop.id} produce_form = Form(self.env['mrp.product.produce'].with_context(context)) - produce_form.product_qty = 1.00 + produce_form.qty_producing = 1.00 custom_laptop_produce = produce_form.save() custom_laptop_produce.do_produce() mo_custom_laptop.post_inventory() @@ -425,7 +425,7 @@ def test_multiple_post_inventory(self): # produce the second item, call `post_inventory` context = {"active_ids": [mo_custom_laptop.id], "active_id": mo_custom_laptop.id} produce_form = Form(self.env['mrp.product.produce'].with_context(context)) - produce_form.product_qty = 1.00 + produce_form.qty_producing = 1.00 custom_laptop_produce = produce_form.save() custom_laptop_produce.do_produce() mo_custom_laptop.post_inventory() @@ -458,7 +458,7 @@ def test_rounding(self): 'active_id': production.id, 'active_ids': [production.id], })) - produce_form.product_qty = 8 + produce_form.qty_producing = 8 produce_wizard = produce_form.save() produce_wizard.do_produce() self.assertEqual(production.move_raw_ids[0].quantity_done, 16, 'Should use half-up rounding when producing') @@ -480,10 +480,9 @@ def test_product_produce_1(self): 'active_ids': [mo.id], })) product_produce = produce_form.save() + self.assertEqual(len(product_produce.workorder_line_ids), 2, 'You should have produce lines even the consumed products are not tracked.') product_produce.do_produce() - self.assertEqual(len(product_produce.produce_line_ids), 2, 'You should have produce lines even the consumed products are not tracked.') - def test_product_produce_2(self): """ Check that line are created when the consumed products are tracked by serial and the lot proposed are correct. """ @@ -511,9 +510,9 @@ def test_product_produce_2(self): })) product_produce = produce_form.save() - self.assertEqual(len(product_produce.produce_line_ids), 3, 'You should have 3 produce lines. One for each serial to consume') - product_produce.product_qty = 1 - produce_line_1 = product_produce.produce_line_ids[0] + self.assertEqual(len(product_produce.workorder_line_ids), 3, 'You should have 3 produce lines. One for each serial to consume') + product_produce.qty_producing = 1 + produce_line_1 = product_produce.workorder_line_ids[0] produce_line_1.qty_done = 1 remaining_lot = (lot_p1_1 | lot_p1_2) - produce_line_1.lot_id product_produce.do_produce() @@ -523,8 +522,8 @@ def test_product_produce_2(self): 'active_ids': [mo.id], })) product_produce = produce_form.save() - self.assertEqual(len(product_produce.produce_line_ids), 2, 'You should have 2 produce lines since one has already be consumed.') - for line in product_produce.produce_line_ids.filtered(lambda x: x.lot_id): + self.assertEqual(len(product_produce.workorder_line_ids), 2, 'You should have 2 produce lines since one has already be consumed.') + for line in product_produce.workorder_line_ids.filtered(lambda x: x.lot_id): self.assertEqual(line.lot_id, remaining_lot, 'Wrong lot proposed.') def test_product_produce_3(self): @@ -560,15 +559,15 @@ def test_product_produce_3(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 1.0 + produce_form.qty_producing = 1.0 product_produce = produce_form.save() - product_produce.lot_id = final_product_lot.id + product_produce.final_lot_id = final_product_lot.id # product 1 lot 1 shelf1 # product 1 lot 1 shelf2 # product 1 lot 2 - self.assertEqual(len(product_produce.produce_line_ids), 4, 'You should have 4 produce lines. lot 1 shelf_1, lot 1 shelf_2, lot2 and for product which have tracking None') + self.assertEqual(len(product_produce.workorder_line_ids), 4, 'You should have 4 produce lines. lot 1 shelf_1, lot 1 shelf_2, lot2 and for product which have tracking None') - for produce_line in product_produce.produce_line_ids: + for produce_line in product_produce.workorder_line_ids: produce_line.qty_done = produce_line.qty_to_consume + 1 product_produce.do_produce() @@ -614,9 +613,10 @@ def test_product_produce_4(self): 'active_id': mo.id, 'active_ids': [mo.id], }).create({ - 'product_qty': 1.0, + 'qty_producing': 1.0, }) - product_produce._onchange_product_qty() + line_values = product_produce._update_workorder_lines() + product_produce.workorder_line_ids |= product_produce.workorder_line_ids.create(line_values['to_create']) product_produce.do_produce() ml_p1 = mo.move_raw_ids.filtered(lambda x: x.product_id == p1).mapped('move_line_ids') @@ -658,11 +658,11 @@ def test_product_produce_5(self): 'active_id': mo.id, 'active_ids': [mo.id], }).create({ - 'product_qty': 5.0, + 'qty_producing': 5.0, }) - produce_wizard._onchange_product_qty() + produce_wizard._onchange_qty_producing() - for produce_line in produce_wizard.produce_line_ids: + for produce_line in produce_wizard.workorder_line_ids: produce_line.qty_done = produce_line.qty_to_consume produce_wizard.do_produce() @@ -705,11 +705,11 @@ def test_product_produce_uom(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.lot_id = final_product_lot + produce_form.final_lot_id = final_product_lot product_produce = produce_form.save() - self.assertEqual(product_produce.product_qty, 1) + self.assertEqual(product_produce.qty_producing, 1) self.assertEqual(product_produce.product_uom_id, unit, 'Should be 1 unit since the tracking is serial.') - product_produce.lot_id = final_product_lot.id + product_produce.final_lot_id = final_product_lot.id product_produce.do_produce() move_line_raw = mo.move_raw_ids.mapped('move_line_ids').filtered(lambda m: m.qty_done) diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index 522ef94342915..a6a8319d4ea0a 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -70,7 +70,7 @@ def test_procurement(self): 'active_id': produce_product_4.id, 'active_ids': [produce_product_4.id], })) - produce_form.product_qty = produce_product_4.product_qty + produce_form.qty_producing = produce_product_4.product_qty product_produce = produce_form.save() product_produce.do_produce() produce_product_4.post_inventory() @@ -96,7 +96,7 @@ def test_procurement(self): 'active_id': production_product_6.id, 'active_ids': [production_product_6.id], })) - produce_form.product_qty = production_product_6.product_qty + produce_form.qty_producing = production_product_6.product_qty product_produce = produce_form.save() product_produce.do_produce() production_product_6.post_inventory() diff --git a/addons/mrp/tests/test_traceability.py b/addons/mrp/tests/test_traceability.py index 4b7918bb0ca32..7f94f17ba8c15 100644 --- a/addons/mrp/tests/test_traceability.py +++ b/addons/mrp/tests/test_traceability.py @@ -70,10 +70,10 @@ def test_tracking_types_on_mo(self): })) if finished_product.tracking != 'serial': - produce_form.product_qty = 1 + produce_form.qty_producing = 1 if finished_product.tracking != 'none': - produce_form.lot_id = self.env['stock.production.lot'].create({'name': 'Serial or Lot finished', 'product_id': finished_product.id}) + produce_form.final_lot_id = self.env['stock.production.lot'].create({'name': 'Serial or Lot finished', 'product_id': finished_product.id}) produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -113,4 +113,3 @@ def test_tracking_types_on_mo(self): unfoldable, 'Parts with tracking type "%s", should have be unfoldable : %s' % (tracking, unfoldable) ) - diff --git a/addons/mrp/tests/test_unbuild.py b/addons/mrp/tests/test_unbuild.py index 19dde75622b91..28a436c9869c1 100644 --- a/addons/mrp/tests/test_unbuild.py +++ b/addons/mrp/tests/test_unbuild.py @@ -28,7 +28,7 @@ def test_unbuild_standart(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 5.0 + produce_form.qty_producing = 5.0 produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -100,8 +100,8 @@ def test_unbuild_with_final_lot(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 5.0 - produce_form.lot_id = lot + produce_form.qty_producing = 5.0 + produce_form.final_lot_id = lot produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -185,7 +185,7 @@ def test_unbuild_with_comnsumed_lot(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 5.0 + produce_form.qty_producing = 5.0 produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -275,8 +275,8 @@ def test_unbuild_with_everything_tracked(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 5.0 - produce_form.lot_id = lot_final + produce_form.qty_producing = 5.0 + produce_form.final_lot_id = lot_final produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -372,7 +372,7 @@ def test_unbuild_with_duplicate_move(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 5.0 + produce_form.qty_producing = 5.0 produce_wizard = produce_form.save() produce_wizard.do_produce() @@ -398,8 +398,8 @@ def test_unbuild_with_duplicate_move(self): self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_1), 1, 'You should have get your product with lot 1 in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 3, 'You should have the 3 basic product for lot 2 in stock') self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_3), 2, 'You should have get one product back for lot 3') - - + + def test_production_links_with_non_tracked_lots(self): """ This test produces an MO in two times and checks that the move lines are linked in a correct way """ @@ -408,43 +408,43 @@ def test_production_links_with_non_tracked_lots(self): 'name': 'lot_1', 'product_id': p2.id, }) - + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 3, lot_id=lot_1) lot_finished_1 = self.env['stock.production.lot'].create({ 'name': 'lot_finished_1', 'product_id': p_final.id, }) - + produce_form = Form(self.env['mrp.product.produce'].with_context({ 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 3.0 - produce_form.lot_id = lot_finished_1 + produce_form.qty_producing = 3.0 + produce_form.final_lot_id = lot_finished_1 produce_wizard = produce_form.save() - produce_wizard.produce_line_ids[0].lot_id = lot_1 + produce_wizard.workorder_line_ids[0].lot_id = lot_1 produce_wizard.do_produce() - + lot_2 = self.env['stock.production.lot'].create({ 'name': 'lot_2', 'product_id': p2.id, }) - + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 4, lot_id=lot_2) lot_finished_2 = self.env['stock.production.lot'].create({ 'name': 'lot_finished_2', 'product_id': p_final.id, }) - + produce_form = Form(self.env['mrp.product.produce'].with_context({ 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 2.0 - produce_form.lot_id = lot_finished_2 + produce_form.qty_producing = 2.0 + produce_form.final_lot_id = lot_finished_2 produce_wizard = produce_form.save() - produce_wizard.produce_line_ids[0].lot_id = lot_2 + produce_wizard.workorder_line_ids[0].lot_id = lot_2 produce_wizard.do_produce() mo.button_mark_done() ml = mo.finished_move_line_ids[0].consume_line_ids.filtered(lambda m: m.product_id == p1 and m.lot_produced_id == lot_finished_1) @@ -453,9 +453,9 @@ def test_production_links_with_non_tracked_lots(self): self.assertEqual(ml[0].qty_done, 8.0, 'Should have consumed 8 for the second lot') def test_unbuild_with_routes(self): - """ This test creates a MO of a stockable product (Table). A new route for rule QC/Unbuild -> Stock + """ This test creates a MO of a stockable product (Table). A new route for rule QC/Unbuild -> Stock is created with Warehouse -> True. - The unbuild order should revert the consumed components into QC/Unbuild location for quality check + The unbuild order should revert the consumed components into QC/Unbuild location for quality check and then a picking should be generated for transferring components from QC/Unbuild location to stock. """ StockQuant = self.env['stock.quant'] @@ -528,7 +528,7 @@ def test_unbuild_with_routes(self): 'active_id': mo.id, 'active_ids': [mo.id], })) - produce_form.product_qty = 1.0 + produce_form.qty_producing = 1.0 produce_wizard = produce_form.save() produce_wizard.do_produce() diff --git a/addons/mrp/tests/test_warehouse_multistep_manufacturing.py b/addons/mrp/tests/test_warehouse_multistep_manufacturing.py index f02d9bba70f45..a0d78d3cc78d5 100644 --- a/addons/mrp/tests/test_warehouse_multistep_manufacturing.py +++ b/addons/mrp/tests/test_warehouse_multistep_manufacturing.py @@ -187,7 +187,7 @@ def test_manufacturing_flow(self): 'active_id': production_order.id, 'active_ids': [production_order.id], })) - produce_form.product_qty = production_order.product_qty + produce_form.qty_producing = production_order.product_qty product_produce = produce_form.save() product_produce.do_produce() production_order.button_mark_done() diff --git a/addons/mrp/tests/test_workorder_operation.py b/addons/mrp/tests/test_workorder_operation.py index fcfce68733da5..b182ff72c032a 100644 --- a/addons/mrp/tests/test_workorder_operation.py +++ b/addons/mrp/tests/test_workorder_operation.py @@ -94,13 +94,13 @@ def test_00_workorder_process(self): finished_lot =self.env['stock.production.lot'].create({'product_id': production_table.product_id.id}) workorder.write({'final_lot_id': finished_lot.id}) workorder.button_start() - for active_move_line_id in workorder.active_move_line_ids: - if active_move_line_id.product_id.id == product_bolt.id: - active_move_line_id.write({'lot_id': lot_bolt.id, 'qty_done': 1}) - if active_move_line_id.product_id.id == product_table_sheet.id: - active_move_line_id.write({'lot_id': lot_sheet.id, 'qty_done': 1}) - if active_move_line_id.product_id.id == product_table_leg.id: - active_move_line_id.write({'lot_id': lot_leg.id, 'qty_done': 1}) + for workorder_line_id in workorder.workorder_line_ids: + if workorder_line_id.product_id.id == product_bolt.id: + workorder_line_id.write({'lot_id': lot_bolt.id, 'qty_done': 1}) + if workorder_line_id.product_id.id == product_table_sheet.id: + workorder_line_id.write({'lot_id': lot_sheet.id, 'qty_done': 1}) + if workorder_line_id.product_id.id == product_table_leg.id: + workorder_line_id.write({'lot_id': lot_leg.id, 'qty_done': 1}) self.assertEqual(workorder.state, 'progress') workorder.record_production() self.assertEqual(workorder.state, 'done') @@ -197,7 +197,7 @@ def test_00b_workorder_process(self): finished_lot = self.env['stock.production.lot'].create({'product_id': production_table.product_id.id}) workorders[0].write({'final_lot_id': finished_lot.id, 'qty_producing': 1.0}) workorders[0].button_start() - workorders[0].active_move_line_ids[0].write({'lot_id': lot_sheet.id, 'qty_done': 1}) + workorders[0].workorder_line_ids[0].write({'lot_id': lot_sheet.id, 'qty_done': 1}) self.assertEqual(workorders[0].state, 'progress') workorders[0].record_production() @@ -209,7 +209,7 @@ def test_00b_workorder_process(self): # --------------------------------------------------------- workorders[1].button_start() workorders[1].qty_producing = 1.0 - workorders[1].active_move_line_ids[0].write({'lot_id': lot_leg.id, 'qty_done': 4}) + workorders[1].workorder_line_ids[0].write({'lot_id': lot_leg.id, 'qty_done': 4}) workorders[1].record_production() move_leg = production_table.move_raw_ids.filtered(lambda p: p.product_id == product_table_leg) #self.assertEqual(workorders[1].state, 'done') @@ -220,7 +220,7 @@ def test_00b_workorder_process(self): # --------------------------------------------------------- workorders[2].button_start() workorders[2].qty_producing = 1.0 - move_lot = workorders[2].active_move_line_ids[0] + move_lot = workorders[2].workorder_line_ids[0] move_lot.write({'lot_id': lot_bolt.id, 'qty_done': 4}) move_table_bolt = production_table.move_raw_ids.filtered(lambda p: p.product_id.id == product_bolt.id) workorders[2].record_production() @@ -338,11 +338,11 @@ def test_01_without_workorder(self): # Produce 6 Unit of custom laptop will consume ( 12 Unit of keybord and 12 Unit of charger) context = {"active_ids": [mo_custom_laptop.id], "active_id": mo_custom_laptop.id} product_form = Form(self.env['mrp.product.produce'].with_context(context)) - product_form.product_qty = 6.00 + product_form.qty_producing = 6.00 laptop_lot_001 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id}) - product_form.lot_id = laptop_lot_001 + product_form.final_lot_id = laptop_lot_001 product_consume = product_form.save() - product_consume.produce_line_ids[0].qty_done = 12 + product_consume.workorder_line_ids[0].qty_done = 12 product_consume.do_produce() # Check consumed move after produce 6 quantity of customized laptop. @@ -365,12 +365,12 @@ def test_01_without_workorder(self): # Produce 4 Unit of custom laptop will consume ( 8 Unit of keybord and 8 Unit of charger). context = {"active_ids": [mo_custom_laptop.id], "active_id": mo_custom_laptop.id} produce_form = Form(self.env['mrp.product.produce'].with_context(context)) - produce_form.product_qty = 4.00 + produce_form.qty_producing = 4.00 laptop_lot_002 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id}) - produce_form.lot_id = laptop_lot_002 + produce_form.final_lot_id = laptop_lot_002 product_consume = produce_form.save() - self.assertEquals(len(product_consume.produce_line_ids), 2) - product_consume.produce_line_ids[0].qty_done = 8 + self.assertEquals(len(product_consume.workorder_line_ids), 2) + product_consume.workorder_line_ids[0].qty_done = 8 product_consume.do_produce() charger_move = mo_custom_laptop.move_raw_ids.filtered(lambda x: x.product_id.id == product_charger.id and x.state != 'done') keybord_move = mo_custom_laptop.move_raw_ids.filtered(lambda x: x.product_id.id == product_keybord.id and x.state !='done') @@ -504,13 +504,13 @@ def test_02_different_uom_on_bomlines(self): mo_custom_product.action_assign() context = {"active_ids": [mo_custom_product.id], "active_id": mo_custom_product.id} produce_form = Form(self.env['mrp.product.produce'].with_context(context)) - produce_form.product_qty = 10.00 - produce_form.lot_id = lot_a + produce_form.qty_producing = 10.00 + produce_form.final_lot_id = lot_a product_consume = produce_form.save() # laptop_lot_002 = self.env['stock.production.lot'].create({'product_id': custom_laptop.id}) - self.assertEquals(len(product_consume.produce_line_ids), 2) - product_consume.produce_line_ids.filtered(lambda x : x.product_id == product_C).write({'qty_done': 3000}) - product_consume.produce_line_ids.filtered(lambda x : x.product_id == product_B).write({'qty_done': 20}) + self.assertEquals(len(product_consume.workorder_line_ids), 2) + product_consume.workorder_line_ids.filtered(lambda x: x.product_id == product_C).write({'qty_done': 3000}) + product_consume.workorder_line_ids.filtered(lambda x: x.product_id == product_B).write({'qty_done': 20}) product_consume.do_produce() mo_custom_product.post_inventory() diff --git a/addons/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml index 570972977656a..be3e4aba4459b 100644 --- a/addons/mrp/views/mrp_workorder_views.xml +++ b/addons/mrp/views/mrp_workorder_views.xml @@ -153,17 +153,14 @@ - + - - - + + + - - - diff --git a/addons/mrp/wizard/change_production_qty.py b/addons/mrp/wizard/change_production_qty.py index b92616c9a5052..a701ddb01b0a3 100644 --- a/addons/mrp/wizard/change_production_qty.py +++ b/addons/mrp/wizard/change_production_qty.py @@ -86,7 +86,7 @@ def change_prod_qty(self): quantity = quantity if (quantity > 0) else 0 if float_is_zero(quantity, precision_digits=precision): wo.final_lot_id = False - wo.active_move_line_ids.unlink() + wo.workorder_line_ids.unlink() wo.qty_producing = quantity if wo.qty_produced < wo.qty_production and wo.state == 'done': wo.state = 'progress' @@ -101,6 +101,11 @@ def change_prod_qty(self): moves_finished = production.move_finished_ids.filtered(lambda move: move.operation_id == operation) #TODO: code does nothing, unless maybe by_products? moves_raw.mapped('move_line_ids').write({'workorder_id': wo.id}) (moves_finished + moves_raw).write({'workorder_id': wo.id}) - if quantity > 0 and wo.move_raw_ids.filtered(lambda x: x.product_id.tracking != 'none') and not wo.active_move_line_ids: - wo._generate_lot_ids() + if wo.state not in ('done', 'cancel'): + line_values = wo._update_workorder_lines() + wo.workorder_line_ids |= wo.workorder_line_ids.create(line_values['to_create']) + if line_values['to_delete']: + line_values['to_delete'].unlink() + for line, vals in line_values['to_update'].items(): + line.write(vals) return {} diff --git a/addons/mrp/wizard/mrp_product_produce.py b/addons/mrp/wizard/mrp_product_produce.py index 309f87abc9a68..b1db108d38060 100644 --- a/addons/mrp/wizard/mrp_product_produce.py +++ b/addons/mrp/wizard/mrp_product_produce.py @@ -4,13 +4,14 @@ from datetime import datetime from odoo import api, fields, models, _ -from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError -from odoo.tools import float_compare, float_round +from odoo.tools import float_compare + class MrpProductProduce(models.TransientModel): _name = "mrp.product.produce" _description = "Record Production" + _inherit = ["mrp.abstract.workorder"] @api.model def default_get(self, fields): @@ -32,21 +33,17 @@ def default_get(self, fields): res['product_uom_id'] = todo_uom if 'serial' in fields: res['serial'] = bool(serial_finished) - if 'product_qty' in fields: - res['product_qty'] = todo_quantity + if 'qty_producing' in fields: + res['qty_producing'] = todo_quantity return res serial = fields.Boolean('Requires Serial') - production_id = fields.Many2one('mrp.production', 'Production') - product_id = fields.Many2one('product.product', 'Product') - product_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True) - product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure') - lot_id = fields.Many2one('stock.production.lot', string='Lot/Serial Number') - produce_line_ids = fields.One2many('mrp.product.produce.line', 'product_produce_id', string='Product to Track') - product_tracking = fields.Selection(related="product_id.tracking", readonly=True) + product_tracking = fields.Selection(related="product_id.tracking") is_pending_production = fields.Boolean(compute='_compute_pending_production') + workorder_line_ids = fields.One2many('mrp.product.produce.line', 'product_produce_id') + move_raw_ids = fields.One2many(related='production_id.move_raw_ids') - @api.depends('product_qty') + @api.depends('qty_producing') def _compute_pending_production(self): """ Compute if it exits remaining quantity once the quantity on the current wizard will be processed. The purpose is to display or not @@ -54,7 +51,7 @@ def _compute_pending_production(self): """ for product_produce in self: remaining_qty = product_produce._get_todo(product_produce.production_id) - product_produce.is_pending_production = remaining_qty - product_produce.product_qty > 0.0 + product_produce.is_pending_production = remaining_qty - product_produce.qty_producing > 0.0 def continue_production(self): """ Save current wizard and directly opens a new. """ @@ -67,7 +64,7 @@ def continue_production(self): def action_generate_serial(self): self.ensure_one() product_produce_wiz = self.env.ref('mrp.view_mrp_product_produce_wizard', False) - self.lot_id = self.env['stock.production.lot'].create({ + self.final_lot_id = self.env['stock.production.lot'].create({ 'product_id': self.product_id.id }) return { @@ -88,58 +85,6 @@ def do_produce(self): self._record_production() return {'type': 'ir.actions.act_window_close'} - @api.multi - def check_finished_move_lots(self): - produce_move = self.production_id.move_finished_ids.filtered(lambda x: x.product_id == self.product_id and x.state not in ('done', 'cancel')) - if produce_move and produce_move.product_id.tracking != 'none': - if not self.lot_id: - raise UserError(_('You need to provide a lot for the finished product.')) - existing_move_line = produce_move.move_line_ids.filtered(lambda x: x.lot_id == self.lot_id) - if existing_move_line: - if self.product_id.tracking == 'serial': - raise UserError(_('You cannot produce the same serial number twice.')) - produced_qty = self.product_uom_id._compute_quantity(self.product_qty, existing_move_line.product_uom_id) - existing_move_line.product_uom_qty += produced_qty - existing_move_line.qty_done += produced_qty - else: - vals = { - 'move_id': produce_move.id, - 'product_id': produce_move.product_id.id, - 'production_id': self.production_id.id, - 'product_uom_qty': self.product_qty, - 'product_uom_id': self.product_uom_id.id, - 'qty_done': self.product_qty, - 'lot_id': self.lot_id.id, - 'location_id': produce_move.location_id.id, - 'location_dest_id': produce_move.location_dest_id.id, - } - self.env['stock.move.line'].create(vals) - - for pl in self.produce_line_ids: - if pl.qty_done: - if pl.product_id.tracking != 'none' and not pl.lot_id: - raise UserError(_('Please enter a lot or serial number for %s !' % pl.product_id.display_name)) - if not pl.move_id: - # Find move_id that would match - move_id = self.production_id.move_raw_ids.filtered(lambda m: m.product_id == pl.product_id and m.state not in ('done', 'cancel')) - if move_id: - pl.move_id = move_id - else: - # create a move and put it in there - order = self.production_id - pl.move_id = self.env['stock.move'].create({ - 'name': order.name, - 'product_id': pl.product_id.id, - 'product_uom': pl.product_uom_id.id, - 'location_id': order.location_src_id.id, - 'location_dest_id': self.product_id.property_stock_production.id, - 'raw_material_production_id': order.id, - 'group_id': order.procurement_group_id.id, - 'origin': order.name, - 'state': 'confirmed'}) - pl.move_id._generate_consumed_move_line(pl.qty_done, self.lot_id, lot=pl.lot_id) - return True - def _get_todo(self, production): """ This method will return remaining todo quantity of production. """ main_product_moves = production.move_finished_ids.filtered(lambda x: x.product_id.id == production.product_id.id) @@ -147,105 +92,54 @@ def _get_todo(self, production): todo_quantity = todo_quantity if (todo_quantity > 0) else 0 return todo_quantity + @api.multi def _record_production(self): - # Nothing to do for lots since values are created using default data (stock.move.lots) - quantity = self.product_qty + # Check all the product_produce line have a move id (the user can add product + # to consume directly in the wizard) + for line in self.workorder_line_ids: + if not line.move_id: + order = self.production_id + # Find move_id that would match + move_id = order.move_raw_ids.filtered( + lambda m: m.product_id == line.product_id and m.state not in ('done', 'cancel') + ) + if not move_id: + # create a move to assign it to the line + move_id = self.env['stock.move'].create({ + 'name': order.name, + 'reference': order.name, + 'product_id': line.product_id.id, + 'product_uom': line.product_uom_id.id, + 'location_id': order.location_src_id.id, + 'location_dest_id': line.product_id.property_stock_production.id, + 'raw_material_production_id': order.id, + 'group_id': order.procurement_group_id.id, + 'origin': order.name, + 'state': 'confirmed' + }) + line.move_id = move_id.id + + # Save product produce lines data into stock moves/move lines + quantity = self.qty_producing if float_compare(quantity, 0, precision_rounding=self.product_uom_id.rounding) <= 0: raise UserError(_("The production order for '%s' has no quantity specified.") % self.product_id.display_name) - for move in self.production_id.move_finished_ids: - if move.product_id.tracking == 'none' and move.state not in ('done', 'cancel'): - rounding = move.product_uom.rounding - if move.product_id.id == self.production_id.product_id.id: - move.quantity_done += float_round(quantity, precision_rounding=rounding) - elif move.unit_factor: - # byproducts handling - move.quantity_done += float_round(quantity * move.unit_factor, precision_rounding=rounding) - self.check_finished_move_lots() + self._update_finished_move() + self._update_raw_moves() if self.production_id.state == 'confirmed': self.production_id.write({ 'date_start': datetime.now(), }) - @api.onchange('product_qty') - def _onchange_product_qty(self): - lines = [] - qty_todo = self.product_uom_id._compute_quantity(self.product_qty, self.production_id.product_uom_id, round=False) - for move in self.production_id.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel') and (m.bom_line_id or m.product_uom_qty)): - qty_to_consume = float_round(qty_todo * move.unit_factor, precision_rounding=move.product_uom.rounding) - for move_line in move.move_line_ids: - if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) <= 0: - break - if move_line.lot_produced_id or float_compare(move_line.product_uom_qty, move_line.qty_done, precision_rounding=move.product_uom.rounding) <= 0: - continue - to_consume_in_line = min(qty_to_consume, move_line.product_uom_qty) - lines.append({ - 'move_id': move.id, - 'qty_to_consume': to_consume_in_line, - 'qty_done': to_consume_in_line, - 'lot_id': move_line.lot_id.id, - 'product_uom_id': move.product_uom.id, - 'product_id': move.product_id.id, - 'qty_reserved': min(to_consume_in_line, move_line.product_uom_qty), - }) - qty_to_consume -= to_consume_in_line - if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: - if move.product_id.tracking == 'serial': - while float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: - lines.append({ - 'move_id': move.id, - 'qty_to_consume': 1, - 'qty_done': 1, - 'product_uom_id': move.product_uom.id, - 'product_id': move.product_id.id, - }) - qty_to_consume -= 1 - else: - lines.append({ - 'move_id': move.id, - 'qty_to_consume': qty_to_consume, - 'qty_done': qty_to_consume, - 'product_uom_id': move.product_uom.id, - 'product_id': move.product_id.id, - }) - - self.produce_line_ids = [(0, 0, x) for x in lines] class MrpProductProduceLine(models.TransientModel): - _name = "mrp.product.produce.line" - _description = "Record Production Line" - - product_produce_id = fields.Many2one('mrp.product.produce') - product_id = fields.Many2one('product.product', 'Product') - product_tracking = fields.Selection(related="product_id.tracking", readonly=False) - lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial Number') - qty_to_consume = fields.Float('To Consume', digits=dp.get_precision('Product Unit of Measure')) - product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure') - qty_done = fields.Float('Consumed') - move_id = fields.Many2one('stock.move') - qty_reserved = fields.Float('Reserved') - - @api.onchange('lot_id') - def _onchange_lot_id(self): - """ When the user is encoding a produce line for a tracked product, we apply some logic to - help him. This onchange will automatically switch `qty_done` to 1.0. - """ - res = {} - if self.product_id.tracking == 'serial': - self.qty_done = 1 - return res + _name = 'mrp.product.produce.line' + _inherit = ["mrp.abstract.workorder.line"] + _description = "Record production line" - @api.onchange('qty_done') - def _onchange_qty_done(self): - """ When the user is encoding a produce 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.product_id.tracking == 'serial' and self.qty_done: - if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.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 + product_produce_id = fields.Many2one('mrp.product.produce', 'Produce wizard') + + def _get_final_lot(self): + return self.product_produce_id.final_lot_id - @api.onchange('product_id') - def _onchange_product_id(self): - self.product_uom_id = self.product_id.uom_id.id + def _get_production(self): + return self.product_produce_id.production_id diff --git a/addons/mrp/wizard/mrp_product_produce_views.xml b/addons/mrp/wizard/mrp_product_produce_views.xml index 964967ad2a0af..2c7954e1a6ecb 100644 --- a/addons/mrp/wizard/mrp_product_produce_views.xml +++ b/addons/mrp/wizard/mrp_product_produce_views.xml @@ -12,22 +12,22 @@ -