# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. from collections import defaultdict import math from flectra import api, fields, models, _ from flectra.addons import decimal_precision as dp from flectra.exceptions import UserError from flectra.tools import float_compare class MrpProduction(models.Model): """ Manufacturing Orders """ _name = 'mrp.production' _description = 'Manufacturing Order' _date_name = 'date_planned_start' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'date_planned_start asc,id' @api.model def _get_default_picking_type(self): return self.env['stock.picking.type'].search([ ('code', '=', 'mrp_operation'), ('warehouse_id.company_id', 'in', [self.env.context.get('company_id', self.env.user.company_id.id), False])], limit=1).id @api.model def _get_default_location_src_id(self): location = False if self._context.get('default_picking_type_id'): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_src_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) return location and location.id or False @api.model def _get_default_location_dest_id(self): location = False if self._context.get('default_picking_type_id'): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_dest_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) return location and location.id or False name = fields.Char( 'Reference', copy=False, readonly=True, default=lambda x: _('New')) origin = fields.Char( 'Source', copy=False, help="Reference of the document that generated this production order request.") product_id = fields.Many2one( 'product.product', 'Product', domain=[('type', 'in', ['product', 'consu'])], readonly=True, required=True, states={'confirmed': [('readonly', False)]}) product_tmpl_id = fields.Many2one('product.template', 'Product Template', related='product_id.product_tmpl_id') product_qty = fields.Float( 'Quantity To Produce', default=1.0, digits=dp.get_precision('Product Unit of Measure'), readonly=True, required=True, track_visibility='onchange', states={'confirmed': [('readonly', False)]}) product_uom_id = fields.Many2one( 'product.uom', 'Product Unit of Measure', oldname='product_uom', readonly=True, required=True, states={'confirmed': [('readonly', False)]}) picking_type_id = fields.Many2one( 'stock.picking.type', 'Operation Type', default=_get_default_picking_type, required=True) location_src_id = fields.Many2one( 'stock.location', 'Raw Materials Location', default=_get_default_location_src_id, readonly=True, required=True, states={'confirmed': [('readonly', False)]}, help="Location where the system will look for components.") location_dest_id = fields.Many2one( 'stock.location', 'Finished Products Location', default=_get_default_location_dest_id, readonly=True, required=True, states={'confirmed': [('readonly', False)]}, help="Location where the system will stock the finished products.") date_planned_start = fields.Datetime( 'Deadline Start', copy=False, default=fields.Datetime.now, index=True, required=True, states={'confirmed': [('readonly', False)]}, oldname="date_planned") date_planned_finished = fields.Datetime( 'Deadline End', copy=False, default=fields.Datetime.now, index=True, states={'confirmed': [('readonly', False)]}) date_start = fields.Datetime('Start Date', copy=False, index=True, readonly=True) date_finished = fields.Datetime('End Date', copy=False, index=True, readonly=True) bom_id = fields.Many2one( 'mrp.bom', 'Bill of Material', readonly=True, states={'confirmed': [('readonly', False)]}, help="Bill of Materials allow you to define the list of required raw materials to make a finished product.") routing_id = fields.Many2one( 'mrp.routing', 'Routing', readonly=True, compute='_compute_routing', store=True, help="The list of operations (list of work centers) to produce the finished product. The routing " "is mainly used to compute work center costs during operations and to plan future loads on " "work centers based on production planning.") move_raw_ids = fields.One2many( 'stock.move', 'raw_material_production_id', 'Raw Materials', oldname='move_lines', copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, domain=[('scrapped', '=', False)]) move_finished_ids = fields.One2many( 'stock.move', 'production_id', 'Finished Products', copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, domain=[('scrapped', '=', False)]) finished_move_line_ids = fields.One2many( 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', string="Finished Product" ) workorder_ids = fields.One2many( 'mrp.workorder', 'production_id', 'Work Orders', copy=False, oldname='workcenter_lines', readonly=True) workorder_count = fields.Integer('# Work Orders', compute='_compute_workorder_count') workorder_done_count = fields.Integer('# Done Work Orders', compute='_compute_workorder_done_count') move_dest_ids = fields.One2many('stock.move', 'created_production_id', string="Stock Movements of Produced Goods") state = fields.Selection([ ('confirmed', 'Confirmed'), ('planned', 'Planned'), ('progress', 'In Progress'), ('done', 'Done'), ('cancel', 'Cancelled')], string='State', copy=False, default='confirmed', track_visibility='onchange') availability = fields.Selection([ ('assigned', 'Available'), ('partially_available', 'Partially Available'), ('waiting', 'Waiting'), ('none', 'None')], string='Materials Availability', compute='_compute_availability', store=True) unreserve_visible = fields.Boolean( 'Allowed to Unreserve Inventory', compute='_compute_unreserve_visible', help='Technical field to check when we can unreserve') post_visible = fields.Boolean( 'Allowed to Post Inventory', compute='_compute_post_visible', help='Technical field to check when we can post') consumed_less_than_planned = fields.Boolean( compute='_compute_consumed_less_than_planned', help='Technical field used to see if we have to display a warning or not when confirming an order.') user_id = fields.Many2one('res.users', 'Responsible', default=lambda self: self._uid) company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('mrp.production'), required=True) check_to_done = fields.Boolean(compute="_get_produced_qty", string="Check Produced Qty", help="Technical Field to see if we can show 'Mark as Done' button") qty_produced = fields.Float(compute="_get_produced_qty", string="Quantity Produced") procurement_group_id = fields.Many2one( 'procurement.group', 'Procurement Group', copy=False) propagate = fields.Boolean( 'Propagate cancel and split', help='If checked, when the previous move of the move (which was generated by a next procurement) is cancelled or split, the move generated by this move will too') has_moves = fields.Boolean(compute='_has_moves') scrap_ids = fields.One2many('stock.scrap', 'production_id', 'Scraps') scrap_count = fields.Integer(compute='_compute_scrap_move_count', string='Scrap Move') priority = fields.Selection([('0', 'Not urgent'), ('1', 'Normal'), ('2', 'Urgent'), ('3', 'Very Urgent')], 'Priority', readonly=True, states={'confirmed': [('readonly', False)]}, default='1') is_locked = fields.Boolean('Is Locked', default=True, copy=False) show_final_lots = fields.Boolean('Show Final Lots', compute='_compute_show_lots') production_location_id = fields.Many2one('stock.location', "Production Location", related='product_id.property_stock_production') @api.depends('product_id.tracking') def _compute_show_lots(self): for production in self: production.show_final_lots = production.product_id.tracking != 'none' def _inverse_lines(self): """ Little hack to make sure that when you change something on these objects, it gets saved""" pass @api.depends('move_finished_ids.move_line_ids') def _compute_lines(self): for production in self: production.finished_move_line_ids = production.move_finished_ids.mapped('move_line_ids') @api.multi @api.depends('bom_id.routing_id', 'bom_id.routing_id.operation_ids') def _compute_routing(self): for production in self: if production.bom_id.routing_id.operation_ids: production.routing_id = production.bom_id.routing_id.id else: production.routing_id = False @api.multi @api.depends('workorder_ids') def _compute_workorder_count(self): data = self.env['mrp.workorder'].read_group([('production_id', 'in', self.ids)], ['production_id'], ['production_id']) count_data = dict((item['production_id'][0], item['production_id_count']) for item in data) for production in self: production.workorder_count = count_data.get(production.id, 0) @api.multi @api.depends('workorder_ids.state') def _compute_workorder_done_count(self): data = self.env['mrp.workorder'].read_group([ ('production_id', 'in', self.ids), ('state', '=', 'done')], ['production_id'], ['production_id']) count_data = dict((item['production_id'][0], item['production_id_count']) for item in data) for production in self: production.workorder_done_count = count_data.get(production.id, 0) @api.multi @api.depends('move_raw_ids.state', 'workorder_ids.move_raw_ids', 'bom_id.ready_to_produce') def _compute_availability(self): for order in self: if not order.move_raw_ids: order.availability = 'none' continue if order.bom_id.ready_to_produce == 'all_available': order.availability = any(move.state not in ('assigned', 'done', 'cancel') for move in order.move_raw_ids) and 'waiting' or 'assigned' else: move_raw_ids = order.move_raw_ids.filtered(lambda m: m.product_qty) partial_list = [x.state in ('partially_available', 'assigned') for x in move_raw_ids] assigned_list = [x.state in ('assigned', 'done', 'cancel') for x in move_raw_ids] order.availability = (all(assigned_list) and 'assigned') or (any(partial_list) and 'partially_available') or 'waiting' @api.depends('move_raw_ids', 'is_locked', 'state', 'move_raw_ids.quantity_done') def _compute_unreserve_visible(self): for order in self: already_reserved = order.is_locked and order.state not in ('done', 'cancel') and order.mapped('move_raw_ids.move_line_ids') any_quantity_done = any([m.quantity_done > 0 for m in order.move_raw_ids]) order.unreserve_visible = not any_quantity_done and already_reserved @api.multi @api.depends('move_raw_ids.quantity_done', 'move_finished_ids.quantity_done', 'is_locked') def _compute_post_visible(self): for order in self: if order.product_tmpl_id._is_cost_method_standard(): order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_raw_ids | order.move_finished_ids) else: order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_finished_ids) @api.multi @api.depends('move_raw_ids.quantity_done', 'move_raw_ids.product_uom_qty') def _compute_consumed_less_than_planned(self): for order in self: order.consumed_less_than_planned = any(order.move_raw_ids.filtered( lambda move: float_compare(move.quantity_done, move.product_uom_qty, precision_rounding=move.product_uom.rounding) == -1) ) @api.multi @api.depends('workorder_ids.state', 'move_finished_ids', 'is_locked') def _get_produced_qty(self): for production in self: done_moves = production.move_finished_ids.filtered(lambda x: x.state != 'cancel' and x.product_id.id == production.product_id.id) qty_produced = sum(done_moves.mapped('quantity_done')) wo_done = True if any([x.state not in ('done', 'cancel') for x in production.workorder_ids]): wo_done = False production.check_to_done = production.is_locked and done_moves and (qty_produced >= production.product_qty) and (production.state not in ('done', 'cancel')) and wo_done production.qty_produced = qty_produced return True @api.multi @api.depends('move_raw_ids') def _has_moves(self): for mo in self: mo.has_moves = any(mo.move_raw_ids) @api.multi def _compute_scrap_move_count(self): data = self.env['stock.scrap'].read_group([('production_id', 'in', self.ids)], ['production_id'], ['production_id']) count_data = dict((item['production_id'][0], item['production_id_count']) for item in data) for production in self: production.scrap_count = count_data.get(production.id, 0) _sql_constraints = [ ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per Company!'), ('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'), ] @api.onchange('product_id', 'picking_type_id', 'company_id') def onchange_product_id(self): """ Finds UoM of changed product. """ if not self.product_id: self.bom_id = False else: bom = self.env['mrp.bom']._bom_find(product=self.product_id, picking_type=self.picking_type_id, company_id=self.company_id.id) if bom.type == 'normal': self.bom_id = bom.id else: self.bom_id = False self.product_uom_id = self.product_id.uom_id.id return {'domain': {'product_uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]}} @api.onchange('picking_type_id') def onchange_picking_type(self): location = self.env.ref('stock.stock_location_stock') self.location_src_id = self.picking_type_id.default_location_src_id.id or location.id self.location_dest_id = self.picking_type_id.default_location_dest_id.id or location.id @api.multi def write (self, vals): res = super(MrpProduction, self).write(vals) if 'date_planned_start' in vals: moves = (self.mapped('move_raw_ids') + self.mapped('move_finished_ids')).filtered( lambda r: r.state not in ['done', 'cancel']) moves.write({ 'date_expected': vals['date_planned_start'], }) return res @api.model def create(self, values): if not values.get('name', False) or values['name'] == _('New'): if values.get('picking_type_id'): values['name'] = self.env['stock.picking.type'].browse(values['picking_type_id']).sequence_id.next_by_id() else: values['name'] = self.env['ir.sequence'].next_by_code('mrp.production') or _('New') if not values.get('procurement_group_id'): values['procurement_group_id'] = self.env["procurement.group"].create({'name': values['name']}).id production = super(MrpProduction, self).create(values) production._generate_moves() return production @api.multi def unlink(self): if any(production.state != 'cancel' for production in self): raise UserError(_('Cannot delete a manufacturing order not in cancel state')) return super(MrpProduction, self).unlink() def action_toggle_is_locked(self): self.ensure_one() self.is_locked = not self.is_locked return True @api.multi def _generate_moves(self): for production in self: production._generate_finished_moves() factor = production.product_uom_id._compute_quantity(production.product_qty, production.bom_id.product_uom_id) / production.bom_id.product_qty boms, lines = production.bom_id.explode(production.product_id, factor, picking_type=production.bom_id.picking_type_id) production._generate_raw_moves(lines) # Check for all draft moves whether they are mto or not production._adjust_procure_method() production.move_raw_ids._action_confirm() return True def _generate_finished_moves(self): move = self.env['stock.move'].create({ 'name': self.name, 'date': self.date_planned_start, 'date_expected': self.date_planned_start, 'product_id': self.product_id.id, 'product_uom': self.product_uom_id.id, 'product_uom_qty': self.product_qty, 'location_id': self.product_id.property_stock_production.id, 'location_dest_id': self.location_dest_id.id, 'company_id': self.company_id.id, 'production_id': self.id, 'origin': self.name, 'group_id': self.procurement_group_id.id, 'propagate': self.propagate, 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids], }) move._action_confirm() return move def _generate_raw_moves(self, exploded_lines): self.ensure_one() moves = self.env['stock.move'] for bom_line, line_data in exploded_lines: moves += self._generate_raw_move(bom_line, line_data) return moves def _generate_raw_move(self, bom_line, line_data): quantity = line_data['qty'] # alt_op needed for the case when you explode phantom bom and all the lines will be consumed in the operation given by the parent bom line alt_op = line_data['parent_line'] and line_data['parent_line'].operation_id.id or False if bom_line.child_bom_id and bom_line.child_bom_id.type == 'phantom': return self.env['stock.move'] if bom_line.product_id.type not in ['product', 'consu']: return self.env['stock.move'] if self.routing_id: routing = self.routing_id else: routing = self.bom_id.routing_id if routing and routing.location_id: source_location = routing.location_id else: source_location = self.location_src_id original_quantity = self.product_qty - self.qty_produced data = { 'sequence': bom_line.sequence, 'name': self.name, 'date': self.date_planned_start, 'date_expected': self.date_planned_start, 'bom_line_id': bom_line.id, 'product_id': bom_line.product_id.id, 'product_uom_qty': quantity, 'product_uom': bom_line.product_uom_id.id, 'location_id': source_location.id, 'location_dest_id': self.product_id.property_stock_production.id, 'raw_material_production_id': self.id, 'company_id': self.company_id.id, 'operation_id': bom_line.operation_id.id or alt_op, 'price_unit': bom_line.product_id.standard_price, 'procure_method': 'make_to_stock', 'origin': self.name, 'warehouse_id': source_location.get_warehouse().id, 'group_id': self.procurement_group_id.id, 'propagate': self.propagate, 'unit_factor': quantity / original_quantity, } return self.env['stock.move'].create(data) @api.multi def _adjust_procure_method(self): try: mto_route = self.env['stock.warehouse']._get_mto_route() except: mto_route = False for move in self.move_raw_ids: product = move.product_id routes = product.route_ids + product.route_from_categ_ids # TODO: optimize with read_group? pull = self.env['procurement.rule'].search([('route_id', 'in', [x.id for x in routes]), ('location_src_id', '=', move.location_id.id), ('location_id', '=', move.location_dest_id.id)], limit=1) if pull and (pull.procure_method == 'make_to_order'): move.procure_method = pull.procure_method elif not pull: # If there is no make_to_stock rule either if mto_route and mto_route.id in [x.id for x in routes]: move.procure_method = 'make_to_order' @api.multi def _update_raw_move(self, bom_line, line_data): quantity = line_data['qty'] self.ensure_one() move = self.move_raw_ids.filtered(lambda x: x.bom_line_id.id == bom_line.id and x.state not in ('done', 'cancel')) if move: if quantity > 0: move[0].write({'product_uom_qty': quantity}) elif quantity < 0: # Do not remove 0 lines if move[0].quantity_done > 0: raise UserError(_('Lines need to be deleted, but can not as you still have some quantities to consume in them. ')) move[0]._action_cancel() move[0].unlink() return move else: self._generate_raw_move(bom_line, line_data) @api.multi def action_assign(self): for production in self: production.move_raw_ids._action_assign() return True @api.multi def open_produce_product(self): self.ensure_one() action = self.env.ref('mrp.act_mrp_product_produce').read()[0] return action @api.multi def button_plan(self): """ Create work orders. And probably do stuff, like things. """ orders_to_plan = self.filtered(lambda order: order.routing_id and order.state == 'confirmed') for order in orders_to_plan: quantity = order.product_uom_id._compute_quantity(order.product_qty, order.bom_id.product_uom_id) / order.bom_id.product_qty boms, lines = order.bom_id.explode(order.product_id, quantity, picking_type=order.bom_id.picking_type_id) order._generate_workorders(boms) return orders_to_plan.write({'state': 'planned'}) @api.multi def _generate_workorders(self, exploded_boms): workorders = self.env['mrp.workorder'] for bom, bom_data in exploded_boms: # If the routing of the parent BoM and phantom BoM are the same, don't recreate work orders, but use one master routing if bom.routing_id.id and (not bom_data['parent_line'] or bom_data['parent_line'].bom_id.routing_id.id != bom.routing_id.id): workorders += self._workorders_create(bom, bom_data) return workorders def _workorders_create(self, bom, bom_data): """ :param bom: in case of recursive boms: we could create work orders for child BoMs """ workorders = self.env['mrp.workorder'] bom_qty = bom_data['qty'] # Initial qty producing if self.product_id.tracking == 'serial': quantity = 1.0 else: quantity = self.product_qty - sum(self.move_finished_ids.mapped('quantity_done')) quantity = quantity if (quantity > 0) else 0 for operation in bom.routing_id.operation_ids: # create workorder cycle_number = math.ceil(bom_qty / operation.workcenter_id.capacity) # TODO: float_round UP duration_expected = (operation.workcenter_id.time_start + operation.workcenter_id.time_stop + cycle_number * operation.time_cycle * 100.0 / operation.workcenter_id.time_efficiency) workorder = workorders.create({ 'name': operation.name, 'production_id': self.id, 'workcenter_id': operation.workcenter_id.id, 'operation_id': operation.id, 'duration_expected': duration_expected, 'state': len(workorders) == 0 and 'ready' or 'pending', 'qty_producing': quantity, 'capacity': operation.workcenter_id.capacity, }) if workorders: workorders[-1].next_work_order_id = workorder.id workorders += workorder # assign moves; last operation receive all unassigned moves (which case ?) moves_raw = self.move_raw_ids.filtered(lambda move: move.operation_id == operation) if len(workorders) == len(bom.routing_id.operation_ids): moves_raw |= self.move_raw_ids.filtered(lambda move: not move.operation_id) moves_finished = self.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': workorder.id}) (moves_finished + moves_raw).write({'workorder_id': workorder.id}) workorder._generate_lot_ids() return workorders @api.multi def action_cancel(self): """ Cancels production order, unfinished stock moves and set procurement orders in exception """ if any(workorder.state == 'progress' for workorder in self.mapped('workorder_ids')): raise UserError(_('You can not cancel production order, a work order is still in progress.')) for production in self: production.workorder_ids.filtered(lambda x: x.state != 'cancel').action_cancel() finish_moves = production.move_finished_ids.filtered(lambda x: x.state not in ('done', 'cancel')) raw_moves = production.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) (finish_moves | raw_moves)._action_cancel() self.write({'state': 'cancel', 'is_locked': True}) return True def _cal_price(self, consumed_moves): self.ensure_one() return True @api.multi def post_inventory(self): for order in self: moves_not_to_do = order.move_raw_ids.filtered(lambda x: x.state == 'done') moves_to_do = order.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) for move in moves_to_do.filtered(lambda m: m.product_qty == 0.0 and m.quantity_done > 0): move.product_uom_qty = move.quantity_done moves_to_do._action_done() moves_to_do = order.move_raw_ids.filtered(lambda x: x.state == 'done') - moves_not_to_do order._cal_price(moves_to_do) moves_to_finish = order.move_finished_ids.filtered(lambda x: x.state not in ('done','cancel')) moves_to_finish._action_done() #order.action_assign() consume_move_lines = moves_to_do.mapped('active_move_line_ids') for moveline in moves_to_finish.mapped('active_move_line_ids'): if moveline.product_id == order.product_id and moveline.move_id.has_tracking != 'none': if any([not ml.lot_produced_id for ml in consume_move_lines]): raise UserError(_('You can not consume without telling for which lot you consumed it')) # Link all movelines in the consumed with same lot_produced_id false or the correct lot_produced_id filtered_lines = consume_move_lines.filtered(lambda x: x.lot_produced_id == moveline.lot_id) moveline.write({'consume_line_ids': [(6, 0, [x for x in filtered_lines.ids])]}) else: # Link with everything moveline.write({'consume_line_ids': [(6, 0, [x for x in consume_move_lines.ids])]}) return True @api.multi def button_mark_done(self): self.ensure_one() for wo in self.workorder_ids: if wo.time_ids.filtered(lambda x: (not x.date_end) and (x.loss_type in ('productive', 'performance'))): raise UserError(_('Work order %s is still running') % wo.name) self.post_inventory() moves_to_cancel = (self.move_raw_ids | self.move_finished_ids).filtered(lambda x: x.state not in ('done', 'cancel')) moves_to_cancel._action_cancel() self.write({'state': 'done', 'date_finished': fields.Datetime.now()}) return self.write({'state': 'done'}) @api.multi def do_unreserve(self): for production in self: production.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel'))._do_unreserve() return True @api.multi def button_unreserve(self): self.ensure_one() self.do_unreserve() return True @api.multi def button_scrap(self): self.ensure_one() return { 'name': _('Scrap'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.scrap', 'view_id': self.env.ref('stock.stock_scrap_form_view2').id, 'type': 'ir.actions.act_window', 'context': {'default_production_id': self.id, 'product_ids': (self.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) | self.move_finished_ids.filtered(lambda x: x.state == 'done')).mapped('product_id').ids, }, 'target': 'new', } @api.multi def action_see_move_scrap(self): self.ensure_one() action = self.env.ref('stock.action_stock_scrap').read()[0] action['domain'] = [('production_id', '=', self.id)] return action