Previous fix: odoo/odoo#139013 added in the ability to handle a use case specific avoided due to its complexity and it being an edge case. I.e. the ability to do a Put in Pack in a batch picking where there is a shipping connector involved (i.e. when the `choose_delivery_package` wizard is opened). Because the ability to handle this situation is now added to stable, we have to sort of support it now and handle it not breaking other flows. Here are the flows that need to be handled (and were broken by the previous PR): [In all cases, "Packages" setting needs to be activated and each picking needs at least 1 move of a consumable/storable product] Flow 1: batch picking + put in pack for single picking - Create 2 pickings of any operation type - Create a new batch picking with these 2 pickings - Open 1 of those pickings directly (i.e. not in the batch) - Click on "Put in Pack" Expected result: Only the move from the open picking is put into a package Result before this commit: Both pickings have their moves put into the same package Additional notes: Because this is not an obvious bug, users may already had this bug occur in their DBs without realizing it === Flow 2: batch picking (or multi-record calling of `action_put_in_pack`) [different in v17 onwards due to removal of immediate_transfer boolean] - Create 2 pickings (of different picking types) - Select both pickings (through direct call in shell or rpc) and call `action_put_in_pack` Expected result: Moves are blocked from being put into same package since this situation doesn't make sense (i.e. the products are moved to different locations but the package can only be in 1 location) Result before this commit: The moves will all be put into the same package Additional notes: In theory batch picking creation has checks to avoid batches where there are pickings with more than 1 picking type or have different `show_reserved` values, but because `_package_move_lines` is a method that can be called in different use cases (including multi-record pickings) via customizations/future code changes, we add in checks to prevent put in pack from finishing in those cases to avoid unexpected behavior/stack traces. I.e. remember to respect existing `self.ensure_one` checks since they're probably there for a reason. === Flow 3: batch picking w/pickings w/more than 1 delivery carriers (where none = a different carrier than having 1) - Create 2 delivery pickings with different `carrier_id` values (i.e. different shipping methods assigned to them) - Add both pickings to a batch - Click "Put in Pack" in the batch picking Expected result: None, we should not handle this case because if the products are in the same package then the same package info will be sent to both carriers and the user will be double charged for every move (or charged(/potentially create the wrong shipping documents) when it shouldn't be in case of no carrier for one of the pickings) Result before this commit: All moves are put in the same package and the double charging/potentially incorrect shipping documents will occur Additional notes: This is the use case that was intended to be avoided when flow was originally decided to not be handled closes odoo/odoo#157224 X-original-commit: b3498facab77e1eed8969c018e3f966a80e62654 Signed-off-by: Tiffany Chang (tic) <tic@odoo.com>
305 lines
14 KiB
Python
305 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from markupsafe import Markup
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from odoo.osv.expression import AND
|
|
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
|
|
|
|
class StockPickingBatch(models.Model):
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_name = "stock.picking.batch"
|
|
_description = "Batch Transfer"
|
|
_order = "name desc"
|
|
|
|
name = fields.Char(
|
|
string='Batch Transfer', default='New',
|
|
copy=False, required=True, readonly=True)
|
|
user_id = fields.Many2one(
|
|
'res.users', string='Responsible', tracking=True, check_company=True)
|
|
company_id = fields.Many2one(
|
|
'res.company', string="Company", required=True, readonly=True,
|
|
index=True, default=lambda self: self.env.company)
|
|
picking_ids = fields.One2many(
|
|
'stock.picking', 'batch_id', string='Transfers',
|
|
domain="[('id', 'in', allowed_picking_ids)]", check_company=True,
|
|
help='List of transfers associated to this batch')
|
|
show_check_availability = fields.Boolean(
|
|
compute='_compute_move_ids',
|
|
string='Show Check Availability')
|
|
show_allocation = fields.Boolean(
|
|
compute='_compute_show_allocation',
|
|
string='Show Allocation Button')
|
|
allowed_picking_ids = fields.One2many('stock.picking', compute='_compute_allowed_picking_ids')
|
|
move_ids = fields.One2many(
|
|
'stock.move', string="Stock moves", compute='_compute_move_ids')
|
|
move_line_ids = fields.One2many(
|
|
'stock.move.line', string='Stock move lines',
|
|
compute='_compute_move_ids', inverse='_set_move_line_ids')
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('in_progress', 'In progress'),
|
|
('done', 'Done'),
|
|
('cancel', 'Cancelled')], default='draft',
|
|
store=True, compute='_compute_state',
|
|
copy=False, tracking=True, required=True, readonly=True, index=True)
|
|
picking_type_id = fields.Many2one(
|
|
'stock.picking.type', 'Operation Type', check_company=True, copy=False,
|
|
index=True)
|
|
picking_type_code = fields.Selection(
|
|
related='picking_type_id.code')
|
|
scheduled_date = fields.Datetime(
|
|
'Scheduled Date', copy=False, store=True, readonly=False, compute="_compute_scheduled_date",
|
|
help="""Scheduled date for the transfers to be processed.
|
|
- If manually set then scheduled date for all transfers in batch will automatically update to this date.
|
|
- If not manually changed and transfers are added/removed/updated then this will be their earliest scheduled date
|
|
but this scheduled date will not be set for all transfers in batch.""")
|
|
is_wave = fields.Boolean('This batch is a wave')
|
|
# To remove in master
|
|
show_set_qty_button = fields.Boolean(compute='_compute_show_qty_button')
|
|
show_clear_qty_button = fields.Boolean(compute='_compute_show_qty_button')
|
|
show_lots_text = fields.Boolean(compute='_compute_show_lots_text')
|
|
|
|
@api.depends()
|
|
def _compute_show_qty_button(self):
|
|
self.show_set_qty_button = False
|
|
self.show_clear_qty_button = False
|
|
|
|
@api.depends('picking_type_id')
|
|
def _compute_show_lots_text(self):
|
|
for batch in self:
|
|
batch.show_lots_text = batch.picking_ids and batch.picking_ids[0].show_lots_text
|
|
|
|
@api.depends('company_id', 'picking_type_id', 'state')
|
|
def _compute_allowed_picking_ids(self):
|
|
allowed_picking_states = ['waiting', 'confirmed', 'assigned']
|
|
|
|
for batch in self:
|
|
domain_states = list(allowed_picking_states)
|
|
# Allows to add draft pickings only if batch is in draft as well.
|
|
if batch.state == 'draft':
|
|
domain_states.append('draft')
|
|
domain = [
|
|
('company_id', '=', batch.company_id.id),
|
|
('state', 'in', domain_states),
|
|
]
|
|
if batch.picking_type_id:
|
|
domain += [('picking_type_id', '=', batch.picking_type_id.id)]
|
|
batch.allowed_picking_ids = self.env['stock.picking'].search(domain)
|
|
|
|
@api.depends('picking_ids', 'picking_ids.move_line_ids', 'picking_ids.move_ids', 'picking_ids.move_ids.state')
|
|
def _compute_move_ids(self):
|
|
for batch in self:
|
|
batch.move_ids = batch.picking_ids.move_ids
|
|
batch.move_line_ids = batch.picking_ids.move_line_ids
|
|
batch.show_check_availability = any(m.state not in ['assigned', 'done'] for m in batch.move_ids)
|
|
|
|
@api.depends('state', 'move_ids', 'picking_type_id')
|
|
def _compute_show_allocation(self):
|
|
self.show_allocation = False
|
|
if not self.user_has_groups('stock.group_reception_report'):
|
|
return
|
|
for batch in self:
|
|
batch.show_allocation = batch.picking_ids._get_show_allocation(batch.picking_type_id)
|
|
|
|
@api.depends('picking_ids', 'picking_ids.state')
|
|
def _compute_state(self):
|
|
batchs = self.filtered(lambda batch: batch.state not in ['cancel', 'done'])
|
|
for batch in batchs:
|
|
if not batch.picking_ids:
|
|
continue
|
|
# Cancels automatically the batch picking if all its transfers are cancelled.
|
|
if all(picking.state == 'cancel' for picking in batch.picking_ids):
|
|
batch.state = 'cancel'
|
|
# Batch picking is marked as done if all its not canceled transfers are done.
|
|
elif all(picking.state in ['cancel', 'done'] for picking in batch.picking_ids):
|
|
batch.state = 'done'
|
|
|
|
@api.depends('picking_ids', 'picking_ids.scheduled_date')
|
|
def _compute_scheduled_date(self):
|
|
for rec in self:
|
|
rec.scheduled_date = min(rec.picking_ids.filtered('scheduled_date').mapped('scheduled_date'), default=False)
|
|
|
|
@api.onchange('scheduled_date')
|
|
def onchange_scheduled_date(self):
|
|
if self.scheduled_date:
|
|
self.picking_ids.scheduled_date = self.scheduled_date
|
|
|
|
def _set_move_line_ids(self):
|
|
new_move_lines = self[0].move_line_ids
|
|
for picking in self.picking_ids:
|
|
old_move_lines = picking.move_line_ids
|
|
picking.move_line_ids = new_move_lines.filtered(lambda ml: ml.picking_id.id == picking.id)
|
|
move_lines_to_unlink = old_move_lines - new_move_lines
|
|
if move_lines_to_unlink:
|
|
move_lines_to_unlink.unlink()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CRUD
|
|
# -------------------------------------------------------------------------
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if vals.get('name', '/') == '/':
|
|
company_id = vals.get('company_id', self.env.company.id)
|
|
if vals.get('is_wave'):
|
|
vals['name'] = self.env['ir.sequence'].with_company(company_id).next_by_code('picking.wave') or '/'
|
|
else:
|
|
vals['name'] = self.env['ir.sequence'].with_company(company_id).next_by_code('picking.batch') or '/'
|
|
return super().create(vals_list)
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if not self.picking_ids:
|
|
self.filtered(lambda b: b.state == 'in_progress').action_cancel()
|
|
if vals.get('picking_type_id'):
|
|
self._sanity_check()
|
|
if vals.get('picking_ids'):
|
|
batch_without_picking_type = self.filtered(lambda batch: not batch.picking_type_id)
|
|
if batch_without_picking_type:
|
|
picking = self.picking_ids and self.picking_ids[0]
|
|
batch_without_picking_type.picking_type_id = picking.picking_type_id.id
|
|
if vals.get('user_id'):
|
|
self.picking_ids.assign_batch_user(vals['user_id'])
|
|
return res
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_if_not_done(self):
|
|
if any(batch.state == 'done' for batch in self):
|
|
raise UserError(_("You cannot delete Done batch transfers."))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Action methods
|
|
# -------------------------------------------------------------------------
|
|
def action_confirm(self):
|
|
"""Sanity checks, confirm the pickings and mark the batch as confirmed."""
|
|
self.ensure_one()
|
|
if not self.picking_ids:
|
|
raise UserError(_("You have to set some pickings to batch."))
|
|
self.picking_ids.action_confirm()
|
|
self._check_company()
|
|
self.state = 'in_progress'
|
|
return True
|
|
|
|
def action_cancel(self):
|
|
self.state = 'cancel'
|
|
self.picking_ids = False
|
|
return True
|
|
|
|
def action_print(self):
|
|
self.ensure_one()
|
|
return self.env.ref('stock_picking_batch.action_report_picking_batch').report_action(self)
|
|
|
|
def action_done(self):
|
|
def has_no_quantity(picking):
|
|
return all(not m.picked or float_is_zero(m.quantity, precision_rounding=m.product_uom.rounding) for m in picking.move_ids if m.state not in ('done', 'cancel'))
|
|
|
|
self.ensure_one()
|
|
self._check_company()
|
|
# Empty 'waiting for another operation' pickings will be removed from the batch when it is validated.
|
|
pickings = self.mapped('picking_ids').filtered(lambda picking: picking.state not in ('cancel', 'done'))
|
|
empty_waiting_pickings = self.mapped('picking_ids').filtered(lambda p: p.state == 'waiting' and has_no_quantity(p))
|
|
pickings = pickings - empty_waiting_pickings
|
|
|
|
empty_pickings = set()
|
|
for picking in pickings:
|
|
if has_no_quantity(picking):
|
|
empty_pickings.add(picking.id)
|
|
picking.message_post(
|
|
body=Markup("<b>%s:</b> %s <a href=#id=%s&view_type=form&model=stock.picking.batch>%s</a>") % (
|
|
_("Transferred by"),
|
|
_("Batch Transfer"),
|
|
picking.batch_id.id,
|
|
picking.batch_id.name))
|
|
|
|
# Run sanity_check as a batch and ignore the one in button_validate() since it is done here.
|
|
pickings._sanity_check(separate_pickings=False)
|
|
# Skip sanity_check in pickings button_validate() & remove 'waiting' pickings from the batch
|
|
context = {'skip_sanity_check': True, 'pickings_to_detach': empty_waiting_pickings.ids}
|
|
if len(empty_pickings) == len(pickings):
|
|
return pickings.with_context(**context).button_validate()
|
|
else:
|
|
# If some pickings are at least partially done, other pickings (empty & waiting) will be removed from batch without being cancelled in case of no backorder
|
|
pickings = pickings - self.env['stock.picking'].browse(empty_pickings)
|
|
context['pickings_to_detach'] = context['pickings_to_detach'] + list(empty_pickings)
|
|
return pickings.with_context(skip_immediate=True, **context).button_validate()
|
|
|
|
def action_assign(self):
|
|
self.ensure_one()
|
|
self.picking_ids.action_assign()
|
|
|
|
def action_put_in_pack(self):
|
|
""" Action to put move lines with 'Done' quantities into a new pack
|
|
This method follows same logic to stock.picking.
|
|
"""
|
|
self.ensure_one()
|
|
if self.state not in ('done', 'cancel'):
|
|
move_line_ids = self.picking_ids[0]._package_move_lines(batch_pack=True)
|
|
if move_line_ids:
|
|
res = move_line_ids.picking_id[0]._pre_put_in_pack_hook(move_line_ids)
|
|
if res:
|
|
return res
|
|
package = move_line_ids.picking_id._put_in_pack(move_line_ids)
|
|
return move_line_ids.picking_id[0]._post_put_in_pack_hook(package)
|
|
raise UserError(_("Please add 'Done' quantities to the batch picking to create a new pack."))
|
|
|
|
def action_view_reception_report(self):
|
|
action = self.picking_ids[0].action_view_reception_report()
|
|
action['context'] = {'default_picking_ids': self.picking_ids.ids}
|
|
return action
|
|
|
|
def action_open_label_layout(self):
|
|
if self.user_has_groups('stock.group_production_lot') and self.move_line_ids.lot_id:
|
|
view = self.env.ref('stock.picking_label_type_form')
|
|
return {
|
|
'name': _('Choose Type of Labels To Print'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'picking.label.type',
|
|
'views': [(view.id, 'form')],
|
|
'target': 'new',
|
|
'context': {'default_picking_ids': self.picking_ids.ids},
|
|
}
|
|
view = self.env.ref('stock.product_label_layout_form_picking')
|
|
return {
|
|
'name': _('Choose Labels Layout'),
|
|
'type': 'ir.actions.act_window',
|
|
'view_mode': 'form',
|
|
'res_model': 'product.label.layout',
|
|
'views': [(view.id, 'form')],
|
|
'view_id': view.id,
|
|
'target': 'new',
|
|
'context': {
|
|
'default_product_ids': self.move_line_ids.product_id.ids,
|
|
'default_move_ids': self.move_ids.ids,
|
|
'default_move_quantity': 'move'},
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Miscellaneous
|
|
# -------------------------------------------------------------------------
|
|
def _sanity_check(self):
|
|
for batch in self:
|
|
if not batch.picking_ids <= batch.allowed_picking_ids:
|
|
erroneous_pickings = batch.picking_ids - batch.allowed_picking_ids
|
|
raise UserError(_(
|
|
"The following transfers cannot be added to batch transfer %s. "
|
|
"Please check their states and operation types.\n\n"
|
|
"Incompatibilities: %s", batch.name, ', '.join(erroneous_pickings.mapped('name'))))
|
|
|
|
def _track_subtype(self, init_values):
|
|
if 'state' in init_values:
|
|
return self.env.ref('stock_picking_batch.mt_batch_state')
|
|
return super()._track_subtype(init_values)
|
|
|
|
def _is_picking_auto_mergeable(self, picking):
|
|
""" Verifies if a picking can be safely inserted into the batch without violating auto_batch_constrains.
|
|
"""
|
|
res = True
|
|
if self.picking_type_id.batch_max_lines:
|
|
res = res and (len(self.move_ids) + len(picking.move_ids) <= self.picking_type_id.batch_max_lines)
|
|
if self.picking_type_id.batch_max_pickings:
|
|
res = res and (len(self.picking_ids) + 1 <= self.picking_type_id.batch_max_pickings)
|
|
return res
|