flectra/addons/mrp/models/mrp_workorder.py

541 lines
27 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
2018-01-16 11:34:37 +01:00
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
2018-01-16 11:34:37 +01:00
from flectra import api, fields, models, _
from flectra.exceptions import UserError
from flectra.tools import float_compare, float_round
from flectra.addons import decimal_precision as dp
class MrpWorkorder(models.Model):
_name = 'mrp.workorder'
_description = 'Work Order'
_inherit = ['mail.thread']
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',
help='Technical: used in views only')
production_id = fields.Many2one(
'mrp.production', 'Manufacturing Order',
index=True, ondelete='cascade', required=True, track_visibility='onchange',
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(
'product.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.availability', store=True,
help='Technical: used in views and domains only.')
production_state = fields.Selection(
'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',
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(
'Quantity', default=0.0,
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', 'Pending'),
('ready', 'Ready'),
('progress', 'In Progress'),
('done', 'Finished'),
('cancel', 'Cancelled')], string='Status',
default='pending')
date_planned_start = fields.Datetime(
'Scheduled Date Start',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
date_planned_finished = fields.Datetime(
'Scheduled Date Finished',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
date_start = fields.Datetime(
'Effective Start Date',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
date_finished = fields.Datetime(
'Effective End Date',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
duration_expected = fields.Float(
'Expected Duration', digits=(16, 2),
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
help="Expected duration (in minutes)")
duration = fields.Float(
'Real Duration', compute='_compute_duration',
readonly=True, store=True)
duration_unit = fields.Float(
'Duration Per Unit', compute='_compute_duration',
readonly=True, store=True)
duration_percent = fields.Integer(
'Duration Deviation (%)', compute='_compute_duration',
group_operator="avg", readonly=True, store=True)
operation_id = fields.Many2one(
'mrp.routing.workcenter', 'Operation') # Should be used differently as BoM can change in the meantime
worksheet = fields.Binary(
'Worksheet', related='operation_id.worksheet', readonly=True)
move_raw_ids = fields.One2many(
'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)]})
tracking = fields.Selection(related='production_id.product_id.tracking')
time_ids = fields.One2many(
'mrp.workcenter.productivity', 'workorder_id')
is_user_working = fields.Boolean(
'Is the Current User Working', compute='_compute_is_user_working',
help="Technical field indicating whether the current user is working. ")
production_messages = fields.Html('Workorder Message', compute='_compute_production_messages')
next_work_order_id = fields.Many2one('mrp.workorder', "Next Work Order")
scrap_ids = fields.One2many('stock.scrap', 'workorder_id')
scrap_count = fields.Integer(compute='_compute_scrap_move_count', string='Scrap Move')
production_date = fields.Datetime('Production Date', related='production_id.date_planned_start', store=True)
color = fields.Integer('Color', compute='_compute_color')
capacity = fields.Float(
'Capacity', default=1.0,
help="Number of pieces that can be produced in parallel.")
@api.multi
def name_get(self):
return [(wo.id, "%s - %s - %s" % (wo.production_id.name, wo.product_id.name, wo.name)) for wo in self]
@api.one
@api.depends('production_id.product_qty', 'qty_produced')
def _compute_is_produced(self):
rounding = self.production_id.product_uom_id.rounding
self.is_produced = float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0
@api.one
@api.depends('time_ids.duration', 'qty_produced')
def _compute_duration(self):
self.duration = sum(self.time_ids.mapped('duration'))
self.duration_unit = round(self.duration / max(self.qty_produced, 1), 2) # rounding 2 because it is a time
if self.duration_expected:
self.duration_percent = 100 * (self.duration_expected - self.duration) / self.duration_expected
else:
self.duration_percent = 0
def _compute_is_user_working(self):
""" Checks whether the current user is working """
for order in self:
if order.time_ids.filtered(lambda x: (x.user_id.id == self.env.user.id) and (not x.date_end) and (x.loss_type in ('productive', 'performance'))):
order.is_user_working = True
else:
order.is_user_working = False
@api.depends('production_id', 'workcenter_id', 'production_id.bom_id')
def _compute_production_messages(self):
ProductionMessage = self.env['mrp.message']
for workorder in self:
domain = [
('valid_until', '>=', fields.Date.today()),
'|', ('workcenter_id', '=', False), ('workcenter_id', '=', workorder.workcenter_id.id),
'|', '|', '|',
('product_id', '=', workorder.product_id.id),
'&', ('product_id', '=', False), ('product_tmpl_id', '=', workorder.product_id.product_tmpl_id.id),
('bom_id', '=', workorder.production_id.bom_id.id),
('routing_id', '=', workorder.operation_id.routing_id.id)]
messages = ProductionMessage.search(domain).mapped('message')
workorder.production_messages = "<br/>".join(messages) or False
@api.multi
def _compute_scrap_move_count(self):
data = self.env['stock.scrap'].read_group([('workorder_id', 'in', self.ids)], ['workorder_id'], ['workorder_id'])
count_data = dict((item['workorder_id'][0], item['workorder_id_count']) for item in data)
for workorder in self:
workorder.scrap_count = count_data.get(workorder.id, 0)
@api.multi
@api.depends('date_planned_finished', 'production_id.date_planned_finished')
def _compute_color(self):
late_orders = self.filtered(lambda x: x.production_id.date_planned_finished and x.date_planned_finished > x.production_id.date_planned_finished)
for order in late_orders:
order.color = 4
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,
2018-07-09 14:37:58 +02:00
'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 ('date_planned_start' in values or 'date_planned_finished' in values) and any(workorder.state == 'done' for workorder in self):
raise UserError(_('You can not change the finished work order.'))
return super(MrpWorkorder, self).write(values)
def _generate_lot_ids(self):
""" Generate stock move lines """
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 and move.bom_line_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,
})
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)],
order='create_date, id', limit=1)
2018-07-09 14:37:58 +02:00
def _get_byproduct_move_line(self, by_product_move, quantity):
return {
'move_id': by_product_move.id,
'product_id': by_product_move.product_id.id,
'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,
}
@api.multi
def record_production(self):
self.ensure_one()
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.bom_line_id\
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)
else:
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
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:
2018-07-09 14:37:58 +02:00
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:
2018-07-09 14:37:58 +02:00
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
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'))):
2018-07-09 14:37:58 +02:00
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)
# Update workorder quantity produced
self.qty_produced += self.qty_producing
if self.final_lot_id:
self.final_lot_id.use_next_on_work_order_id = self.next_work_order_id
self.final_lot_id = False
# Set a qty producing
rounding = self.production_id.product_uom_id.rounding
if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0:
self.qty_producing = 0
elif self.production_id.product_id.tracking == 'serial':
self._assign_default_final_lot_id()
self.qty_producing = 1.0
self._generate_lot_ids()
else:
self.qty_producing = float_round(self.production_id.product_qty - self.qty_produced, precision_rounding=rounding)
self._generate_lot_ids()
if self.next_work_order_id and self.production_id.product_id.tracking != 'none':
self.next_work_order_id._assign_default_final_lot_id()
if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0:
self.button_finish()
return True
@api.multi
def button_start(self):
2018-07-09 14:37:58 +02:00
self.ensure_one()
# As button_start is automatically called in the new view
if self.state in ('done', 'cancel'):
return True
# Need a loss in case of the real time exceeding the expected
timeline = self.env['mrp.workcenter.productivity']
if self.duration < self.duration_expected:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type','=','productive')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one productivity loss in the category 'Productivity'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
else:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type','=','performance')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
for workorder in self:
if workorder.production_id.state != 'progress':
workorder.production_id.write({
'state': 'progress',
'date_start': datetime.now(),
})
timeline.create({
'workorder_id': workorder.id,
'workcenter_id': workorder.workcenter_id.id,
'description': _('Time Tracking: ')+self.env.user.name,
'loss_id': loss_id[0].id,
'date_start': datetime.now(),
'user_id': self.env.user.id
})
return self.write({'state': 'progress',
'date_start': datetime.now(),
})
@api.multi
def button_finish(self):
self.ensure_one()
self.end_all()
return self.write({'state': 'done', 'date_finished': fields.Datetime.now()})
@api.multi
def end_previous(self, doall=False):
"""
@param: doall: This will close all open time lines on the open work orders when doall = True, otherwise
only the one of the current user
"""
# TDE CLEANME
timeline_obj = self.env['mrp.workcenter.productivity']
domain = [('workorder_id', 'in', self.ids), ('date_end', '=', False)]
if not doall:
domain.append(('user_id', '=', self.env.user.id))
not_productive_timelines = timeline_obj.browse()
for timeline in timeline_obj.search(domain, limit=None if doall else 1):
wo = timeline.workorder_id
if wo.duration_expected <= wo.duration:
if timeline.loss_type == 'productive':
not_productive_timelines += timeline
timeline.write({'date_end': fields.Datetime.now()})
else:
maxdate = fields.Datetime.from_string(timeline.date_start) + relativedelta(minutes=wo.duration_expected - wo.duration)
enddate = datetime.now()
if maxdate > enddate:
timeline.write({'date_end': enddate})
else:
timeline.write({'date_end': maxdate})
not_productive_timelines += timeline.copy({'date_start': maxdate, 'date_end': enddate})
if not_productive_timelines:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one unactive productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
not_productive_timelines.write({'loss_id': loss_id.id})
return True
@api.multi
def end_all(self):
return self.end_previous(doall=True)
@api.multi
def button_pending(self):
self.end_previous()
return True
@api.multi
def button_unblock(self):
for order in self:
order.workcenter_id.unblock()
return True
@api.multi
def action_cancel(self):
return self.write({'state': 'cancel'})
@api.multi
def button_done(self):
if any([x.state in ('done', 'cancel') for x in self]):
raise UserError(_('A Manufacturing Order is already done or cancelled!'))
self.end_all()
return self.write({'state': 'done',
'date_finished': datetime.now()})
@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_workorder_id': self.id, 'default_production_id': self.production_id.id, 'product_ids': (self.production_id.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) | self.production_id.move_finished_ids.filtered(lambda x: x.state == 'done')).mapped('product_id').ids},
# 'context': {'product_ids': self.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')).mapped('product_id').ids + [self.production_id.product_id.id]},
'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'] = [('workorder_id', '=', self.id)]
return action
@api.multi
@api.depends('qty_production', 'qty_produced')
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)