# -*- 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, ValidationError from odoo.tools.float_utils import float_round, float_compare class PackOperation(models.Model): _name = "stock.pack.operation" _description = "Packing Operation" _order = "result_package_id desc, id" # TDE FIXME: strange, probably to remove def _get_default_from_loc(self): default_loc = self.env.context.get('default_location_id') if default_loc: return self.env['stock.location'].browse(default_loc).name # TDE FIXME: strange, probably to remove def _get_default_to_loc(self): default_loc = self.env.context.get('default_location_dest_id') if default_loc: return self.env['stock.location'].browse(default_loc).name picking_id = fields.Many2one( 'stock.picking', 'Stock Picking', required=True, help='The stock operation where the packing has been made') product_id = fields.Many2one('product.product', 'Product', ondelete="cascade") product_uom_id = fields.Many2one('product.uom', 'Unit of Measure') product_qty = fields.Float('To Do', default=0.0, digits=dp.get_precision('Product Unit of Measure'), required=True) ordered_qty = fields.Float('Ordered Quantity', digits=dp.get_precision('Product Unit of Measure')) qty_done = fields.Float('Done', default=0.0, digits=dp.get_precision('Product Unit of Measure')) qty_done_uom_ordered = fields.Float( 'Quantity Done', digits=dp.get_precision('Product Unit of Measure'), compute='_compute_qty_done_uom_ordered', help='Quantity done in UOM ordered') is_done = fields.Boolean(compute='_compute_is_done', string='Done', readonly=False, oldname='processed_boolean') package_id = fields.Many2one('stock.quant.package', 'Source Package') pack_lot_ids = fields.One2many('stock.pack.operation.lot', 'operation_id', 'Lots/Serial Numbers Used') result_package_id = fields.Many2one( 'stock.quant.package', 'Destination Package', ondelete='cascade', required=False, help="If set, the operations are packed into this package") date = fields.Datetime('Date', default=fields.Date.context_today, required=True) owner_id = fields.Many2one('res.partner', 'Owner', help="Owner of the quants") linked_move_operation_ids = fields.One2many( 'stock.move.operation.link', 'operation_id', string='Linked Moves', readonly=True, help='Moves impacted by this operation for the computation of the remaining quantities') remaining_qty = fields.Float( compute='_get_remaining_qty', string="Remaining Qty", digits=0, help="Remaining quantity in default UoM according to moves matched with this operation.") location_id = fields.Many2one('stock.location', 'Source Location', required=True) location_dest_id = fields.Many2one('stock.location', 'Destination Location', required=True) picking_source_location_id = fields.Many2one('stock.location', related='picking_id.location_id') picking_destination_location_id = fields.Many2one('stock.location', related='picking_id.location_dest_id') # TDE FIXME: unnecessary fields IMO, to remove from_loc = fields.Char(compute='_compute_location_description', default=_get_default_from_loc, string='From') to_loc = fields.Char(compute='_compute_location_description', default=_get_default_to_loc, string='To') fresh_record = fields.Boolean('Newly created pack operation', default=True) lots_visible = fields.Boolean(compute='_compute_lots_visible') state = fields.Selection(selection=[ ('draft', 'Draft'), ('cancel', 'Cancelled'), ('waiting', 'Waiting Another Operation'), ('confirmed', 'Waiting Availability'), ('partially_available', 'Partially Available'), ('assigned', 'Available'), ('done', 'Done')], related='picking_id.state') @api.one def _compute_is_done(self): self.is_done = self.qty_done > 0.0 @api.onchange('is_done') def on_change_is_done(self): if not self.product_id: if self.is_done and self.qty_done == 0: self.qty_done = 1.0 if not self.is_done and self.qty_done != 0: self.qty_done = 0.0 def _get_remaining_prod_quantities(self): '''Get the remaining quantities per product on an operation with a package. This function returns a dictionary''' # TDE CLEANME: merge with _get_all_products_quantities in quant to ease code understanding + clean code # if the operation doesn't concern a package, it's not relevant to call this function if not self.package_id or self.product_id: return {self.product_id: self.remaining_qty} # get the total of products the package contains res = self.package_id._get_all_products_quantities() # reduce by the quantities linked to a move for record in self.linked_move_operation_ids: if record.move_id.product_id not in res: res[record.move_id.product_id] = 0 res[record.move_id.product_id] -= record.qty return res @api.one def _get_remaining_qty(self): if self.package_id and not self.product_id: # dont try to compute the remaining quantity for packages because it's not relevant (a package could include different products). # should use _get_remaining_prod_quantities instead # TDE FIXME: actually resolve the comment hereabove self.remaining_qty = 0 else: qty = self.product_qty if self.product_uom_id: qty = self.product_uom_id._compute_quantity(self.product_qty, self.product_id.uom_id) for record in self.linked_move_operation_ids: qty -= record.qty self.remaining_qty = float_round(qty, precision_rounding=self.product_id.uom_id.rounding) @api.multi def _compute_location_description(self): for operation, operation_sudo in zip(self, self.sudo()): operation.from_loc = '%s%s' % (operation_sudo.location_id.name, operation.product_id and operation_sudo.package_id.name or '') operation.to_loc = '%s%s' % (operation_sudo.location_dest_id.name, operation_sudo.result_package_id.name or '') @api.one def _compute_lots_visible(self): if self.pack_lot_ids: self.lots_visible = True elif self.picking_id.picking_type_id and self.product_id.tracking != 'none': # TDE FIXME: not sure correctly migrated picking = self.picking_id self.lots_visible = picking.picking_type_id.use_existing_lots or picking.picking_type_id.use_create_lots else: self.lots_visible = self.product_id.tracking != 'none' @api.multi def _compute_qty_done_uom_ordered(self): for pack in self: if pack.product_uom_id and pack.linked_move_operation_ids: pack.qty_done_uom_ordered = pack.product_uom_id._compute_quantity(pack.qty_done, pack.linked_move_operation_ids[0].move_id.product_uom) else: pack.qty_done_uom_ordered = pack.qty_done @api.onchange('pack_lot_ids') def _onchange_packlots(self): self.qty_done = sum([x.qty for x in self.pack_lot_ids]) @api.multi @api.onchange('product_id', 'product_uom_id') def onchange_product_id(self): if self.product_id: self.lots_visible = self.product_id.tracking != 'none' if not self.product_uom_id or self.product_uom_id.category_id != self.product_id.uom_id.category_id: self.product_uom_id = self.product_id.uom_id.id res = {'domain': {'product_uom_id': [('category_id', '=', self.product_uom_id.category_id.id)]}} else: res = {'domain': {'product_uom_id': []}} return res @api.model def create(self, vals): vals['ordered_qty'] = vals.get('product_qty') return super(PackOperation, self).create(vals) @api.multi def write(self, values): # TDE FIXME: weird stuff, protectin pack op ? values['fresh_record'] = False return super(PackOperation, self).write(values) @api.multi def unlink(self): if any([operation.state in ('done', 'cancel') for operation in self]): raise UserError(_('You can not delete pack operations of a done picking')) return super(PackOperation, self).unlink() @api.multi def split_quantities(self): for operation in self: if float_compare(operation.product_qty, operation.qty_done, precision_rounding=operation.product_uom_id.rounding) == 1: cpy = operation.copy(default={'qty_done': 0.0, 'product_qty': operation.product_qty - operation.qty_done}) operation.write({'product_qty': operation.qty_done}) operation._copy_remaining_pack_lot_ids(cpy) else: raise UserError(_('The quantity to split should be smaller than the quantity To Do. ')) return True @api.multi def save(self): # TDE FIXME: does not seem to be used -> actually, it does # TDE FIXME: move me somewhere else, because the return indicated a wizard, in pack op, it is quite strange # HINT: 4. How to manage lots of identical products? # Create a picking and click on the Mark as TODO button to display the Lot Split icon. A window will pop-up. Click on Add an item and fill in the serial numbers and click on save button for pack in self: if pack.product_id.tracking != 'none': pack.write({'qty_done': sum(pack.pack_lot_ids.mapped('qty'))}) return {'type': 'ir.actions.act_window_close'} @api.multi def action_split_lots(self): action_ctx = dict(self.env.context) # If it's a returned stock move, we do not want to create a lot returned_move = self.linked_move_operation_ids.mapped('move_id').mapped('origin_returned_move_id') picking_type = self.picking_id.picking_type_id action_ctx.update({ 'serial': self.product_id.tracking == 'serial', 'only_create': picking_type.use_create_lots and not picking_type.use_existing_lots and not returned_move, 'create_lots': picking_type.use_create_lots, 'state_done': self.picking_id.state == 'done', 'show_reserved': any([lot for lot in self.pack_lot_ids if lot.qty_todo > 0.0])}) view_id = self.env.ref('stock.view_pack_operation_lot_form').id return { 'name': _('Lot/Serial Number Details'), 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.pack.operation', 'views': [(view_id, 'form')], 'view_id': view_id, 'target': 'new', 'res_id': self.ids[0], 'context': action_ctx} split_lot = action_split_lots @api.multi def show_details(self): # TDE FIXME: does not seem to be used view_id = self.env.ref('stock.view_pack_operation_details_form_save').id return { 'name': _('Operation Details'), 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.pack.operation', 'views': [(view_id, 'form')], 'view_id': view_id, 'target': 'new', 'res_id': self.ids[0], 'context': self.env.context} @api.multi def _check_serial_number(self): for operation in self: if operation.picking_id and \ (operation.picking_id.picking_type_id.use_existing_lots or operation.picking_id.picking_type_id.use_create_lots) and \ operation.product_id and operation.product_id.tracking != 'none' and \ operation.qty_done > 0.0: if not operation.pack_lot_ids: raise UserError(_('You need to provide a Lot/Serial Number for product %s') % operation.product_id.name) if operation.product_id.tracking == 'serial': for opslot in operation.pack_lot_ids: if opslot.qty not in (1.0, 0.0): raise UserError(_('You should provide a different serial number for each piece')) check_tracking = _check_serial_number @api.multi def _copy_remaining_pack_lot_ids(self, new_operation): for op in self: for lot in op.pack_lot_ids: new_qty_todo = lot.qty_todo - lot.qty if float_compare(new_qty_todo, 0, precision_rounding=op.product_uom_id.rounding) > 0: lot.copy({ 'operation_id': new_operation.id, 'qty_todo': new_qty_todo, 'qty': 0, }) class PackOperationLot(models.Model): _name = "stock.pack.operation.lot" _description = "Lot/Serial number for pack ops" operation_id = fields.Many2one('stock.pack.operation') qty = fields.Float('Done', default=1.0, digits=dp.get_precision('Product Unit of Measure')) lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial Number') lot_name = fields.Char('Lot/Serial Number') qty_todo = fields.Float('To Do', default=0.0, digits=dp.get_precision('Product Unit of Measure')) plus_visible = fields.Boolean(compute='_compute_plus_visible', default=True) _sql_constraints = [ ('qty', 'CHECK(qty >= 0.0)', 'Quantity must be greater than or equal to 0.0!'), ('uniq_lot_id', 'unique(operation_id, lot_id)', 'You have already mentioned this lot in another line'), ('uniq_lot_name', 'unique(operation_id, lot_name)', 'You have already mentioned this lot name in another line')] @api.one def _compute_plus_visible(self): if self.operation_id.product_id.tracking == 'serial': self.plus_visible = (self.qty == 0.0) else: self.plus_visible = (self.qty_todo == 0.0) or (self.qty < self.qty_todo) @api.constrains('lot_id', 'lot_name') def _check_lot(self): if any(not lot.lot_name and not lot.lot_id for lot in self): raise ValidationError(_('Lot/Serial Number required')) return True def action_add_quantity(self, quantity): for lot in self: lot.write({'qty': lot.qty + quantity}) lot.operation_id.write({'qty_done': sum(operation_lot.qty for operation_lot in lot.operation_id.pack_lot_ids)}) return self.mapped('operation_id').action_split_lots() @api.multi def do_plus(self): return self.action_add_quantity(1) @api.multi def do_minus(self): return self.action_add_quantity(-1) class OperationLink(models.Model): """ Make link between stock.move and stock.pack.operation in order to compute the remaining quantities on each of those objects. """ _name = "stock.move.operation.link" _description = "Pack Operation / Moves Link" qty = fields.Float( 'Quantity', help="Quantity of products to consider when talking about the contribution of this pack operation towards the " "remaining quantity of the move (and inverse). Given in the product main uom.") operation_id = fields.Many2one( 'stock.pack.operation', 'Operation', ondelete="cascade", required=True) move_id = fields.Many2one( 'stock.move', 'Move', ondelete="cascade", required=True) reserved_quant_id = fields.Many2one( 'stock.quant', 'Reserved Quant', help="Technical field containing the quant that created this link between an operation and a stock move. " "Used at the stock_move_obj.action_done() time to avoid seeking a matching quant again")