1009 lines
57 KiB
Python
1009 lines
57 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from datetime import datetime
|
|
from dateutil import relativedelta
|
|
import time
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.addons import decimal_precision as dp
|
|
from odoo.addons.procurement.models import procurement
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
|
|
from odoo.tools.float_utils import float_compare, float_round, float_is_zero
|
|
|
|
|
|
class StockMove(models.Model):
|
|
_name = "stock.move"
|
|
_description = "Stock Move"
|
|
_order = 'picking_id, sequence, id'
|
|
|
|
def _default_group_id(self):
|
|
if self.env.context.get('default_picking_id'):
|
|
return self.env['stock.picking'].browse(self.env.context['default_picking_id']).group_id.id
|
|
return False
|
|
|
|
name = fields.Char('Description', index=True, required=True)
|
|
sequence = fields.Integer('Sequence', default=10)
|
|
priority = fields.Selection(procurement.PROCUREMENT_PRIORITIES, 'Priority', default='1')
|
|
create_date = fields.Datetime('Creation Date', index=True, readonly=True)
|
|
date = fields.Datetime(
|
|
'Date', default=fields.Datetime.now, index=True, required=True,
|
|
states={'done': [('readonly', True)]},
|
|
help="Move date: scheduled date until move is done, then date of actual move processing")
|
|
company_id = fields.Many2one(
|
|
'res.company', 'Company',
|
|
default=lambda self: self.env['res.company']._company_default_get('stock.move'),
|
|
index=True, required=True)
|
|
date_expected = fields.Datetime(
|
|
'Expected Date', default=fields.Datetime.now, index=True, required=True,
|
|
states={'done': [('readonly', True)]},
|
|
help="Scheduled date for the processing of this move")
|
|
product_id = fields.Many2one(
|
|
'product.product', 'Product',
|
|
domain=[('type', 'in', ['product', 'consu'])], index=True, required=True,
|
|
states={'done': [('readonly', True)]})
|
|
ordered_qty = fields.Float('Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'))
|
|
product_qty = fields.Float(
|
|
'Real Quantity', compute='_compute_product_qty', inverse='_set_product_qty',
|
|
digits=0, store=True,
|
|
help='Quantity in the default UoM of the product')
|
|
product_uom_qty = fields.Float(
|
|
'Quantity',
|
|
digits=dp.get_precision('Product Unit of Measure'),
|
|
default=1.0, required=True, states={'done': [('readonly', True)]},
|
|
help="This is the quantity of products from an inventory "
|
|
"point of view. For moves in the state 'done', this is the "
|
|
"quantity of products that were actually moved. For other "
|
|
"moves, this is the quantity of product that is planned to "
|
|
"be moved. Lowering this quantity does not generate a "
|
|
"backorder. Changing this quantity on assigned moves affects "
|
|
"the product reservation, and should be done with care.")
|
|
product_uom = fields.Many2one(
|
|
'product.uom', 'Unit of Measure', required=True, states={'done': [('readonly', True)]})
|
|
# TDE FIXME: make it stored, otherwise group will not work
|
|
product_tmpl_id = fields.Many2one(
|
|
'product.template', 'Product Template',
|
|
related='product_id.product_tmpl_id',
|
|
help="Technical: used in views")
|
|
product_packaging = fields.Many2one(
|
|
'product.packaging', 'Preferred Packaging',
|
|
help="It specifies attributes of packaging like type, quantity of packaging,etc.")
|
|
location_id = fields.Many2one(
|
|
'stock.location', 'Source Location',
|
|
auto_join=True, index=True, required=True, states={'done': [('readonly', True)]},
|
|
help="Sets a location if you produce at a fixed location. This can be a partner location if you subcontract the manufacturing operations.")
|
|
location_dest_id = fields.Many2one(
|
|
'stock.location', 'Destination Location',
|
|
auto_join=True, index=True, required=True, states={'done': [('readonly', True)]},
|
|
help="Location where the system will stock the finished products.")
|
|
partner_id = fields.Many2one(
|
|
'res.partner', 'Destination Address ',
|
|
states={'done': [('readonly', True)]},
|
|
help="Optional address where goods are to be delivered, specifically used for allotment")
|
|
move_dest_id = fields.Many2one(
|
|
'stock.move', 'Destination Move',
|
|
copy=False, index=True,
|
|
help="Optional: next stock move when chaining them")
|
|
move_orig_ids = fields.One2many(
|
|
'stock.move', 'move_dest_id', 'Original Move',
|
|
help="Optional: previous stock move when chaining them")
|
|
picking_id = fields.Many2one('stock.picking', 'Transfer Reference', index=True, states={'done': [('readonly', True)]})
|
|
picking_partner_id = fields.Many2one('res.partner', 'Transfer Destination Address', related='picking_id.partner_id')
|
|
note = fields.Text('Notes')
|
|
state = fields.Selection([
|
|
('draft', 'New'), ('cancel', 'Cancelled'),
|
|
('waiting', 'Waiting Another Move'), ('confirmed', 'Waiting Availability'),
|
|
('assigned', 'Available'), ('done', 'Done')], string='Status',
|
|
copy=False, default='draft', index=True, readonly=True,
|
|
help="* New: When the stock move is created and not yet confirmed.\n"
|
|
"* Waiting Another Move: This state can be seen when a move is waiting for another one, for example in a chained flow.\n"
|
|
"* Waiting Availability: This state is reached when the procurement resolution is not straight forward. It may need the scheduler to run, a component to be manufactured...\n"
|
|
"* Available: When products are reserved, it is set to \'Available\'.\n"
|
|
"* Done: When the shipment is processed, the state is \'Done\'.")
|
|
partially_available = fields.Boolean('Partially Available', copy=False, readonly=True, help="Checks if the move has some stock reserved")
|
|
price_unit = fields.Float(
|
|
'Unit Price', help="Technical field used to record the product cost set by the user during a picking confirmation (when costing "
|
|
"method used is 'average price' or 'real'). Value given in company currency and in product uom.") # as it's a technical field, we intentionally don't provide the digits attribute
|
|
split_from = fields.Many2one('stock.move', "Move Split From", copy=False, help="Technical field used to track the origin of a split move, which can be useful in case of debug")
|
|
backorder_id = fields.Many2one('stock.picking', 'Back Order of', related='picking_id.backorder_id', index=True)
|
|
origin = fields.Char("Source Document")
|
|
procure_method = fields.Selection([
|
|
('make_to_stock', 'Default: Take From Stock'),
|
|
('make_to_order', 'Advanced: Apply Procurement Rules')], string='Supply Method',
|
|
default='make_to_stock', required=True,
|
|
help="By default, the system will take from the stock in the source location and passively wait for availability."
|
|
"The other possibility allows you to directly create a procurement on the source location (and thus ignore "
|
|
"its current stock) to gather products. If we want to chain moves and have this one to wait for the previous,"
|
|
"this second option should be chosen.")
|
|
scrapped = fields.Boolean('Scrapped', related='location_dest_id.scrap_location', readonly=True, store=True)
|
|
quant_ids = fields.Many2many('stock.quant', 'stock_quant_move_rel', 'move_id', 'quant_id', 'Moved Quants', copy=False)
|
|
reserved_quant_ids = fields.One2many('stock.quant', 'reservation_id', 'Reserved quants')
|
|
linked_move_operation_ids = fields.One2many(
|
|
'stock.move.operation.link', 'move_id', 'Linked Operations', readonly=True,
|
|
help='Operations that impact this move for the computation of the remaining quantities')
|
|
remaining_qty = fields.Float(
|
|
'Remaining Quantity', compute='_get_remaining_qty',
|
|
digits=0, states={'done': [('readonly', True)]},
|
|
help="Remaining Quantity in default UoM according to operations matched with this move")
|
|
procurement_id = fields.Many2one('procurement.order', 'Procurement')
|
|
group_id = fields.Many2one('procurement.group', 'Procurement Group', default=_default_group_id)
|
|
rule_id = fields.Many2one('procurement.rule', 'Procurement Rule', help='The procurement rule that created this stock move')
|
|
push_rule_id = fields.Many2one('stock.location.path', 'Push Rule', help='The push rule that created this stock move')
|
|
propagate = fields.Boolean(
|
|
'Propagate cancel and split', default=True,
|
|
help='If checked, when this move is cancelled, cancel the linked move too')
|
|
picking_type_id = fields.Many2one('stock.picking.type', 'Picking Type')
|
|
inventory_id = fields.Many2one('stock.inventory', 'Inventory')
|
|
lot_ids = fields.Many2many('stock.production.lot', string='Lots/Serial Numbers', compute='_compute_lot_ids')
|
|
origin_returned_move_id = fields.Many2one('stock.move', 'Origin return move', copy=False, help='Move that created the return move')
|
|
returned_move_ids = fields.One2many('stock.move', 'origin_returned_move_id', 'All returned moves', help='Optional: all returned moves created from this move')
|
|
reserved_availability = fields.Float(
|
|
'Quantity Reserved', compute='_compute_reserved_availability',
|
|
readonly=True, help='Quantity that has already been reserved for this move')
|
|
availability = fields.Float(
|
|
'Forecasted Quantity', compute='_compute_product_availability',
|
|
readonly=True, help='Quantity in stock that can still be reserved for this move')
|
|
string_availability_info = fields.Text(
|
|
'Availability', compute='_compute_string_qty_information',
|
|
readonly=True, help='Show various information on stock availability for this move')
|
|
restrict_lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial Number', help="Technical field used to depict a restriction on the lot/serial number of quants to consider when marking this move as 'done'")
|
|
restrict_partner_id = fields.Many2one('res.partner', 'Owner ', help="Technical field used to depict a restriction on the ownership of quants to consider when marking this move as 'done'")
|
|
route_ids = fields.Many2many('stock.location.route', 'stock_location_route_move', 'move_id', 'route_id', 'Destination route', help="Preferred route to be followed by the procurement order")
|
|
warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', help="Technical field depicting the warehouse to consider for the route selection on the next procurement (if any).")
|
|
|
|
@api.one
|
|
@api.depends('product_id', 'product_uom', 'product_uom_qty')
|
|
def _compute_product_qty(self):
|
|
if self.product_uom:
|
|
rounding_method = self._context.get('rounding_method', 'UP')
|
|
self.product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id, rounding_method=rounding_method)
|
|
|
|
def _set_product_qty(self):
|
|
""" The meaning of product_qty field changed lately and is now a functional field computing the quantity
|
|
in the default product UoM. This code has been added to raise an error if a write is made given a value
|
|
for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to
|
|
detect errors. """
|
|
raise UserError(_('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.'))
|
|
|
|
@api.one
|
|
@api.depends('linked_move_operation_ids.qty')
|
|
def _get_remaining_qty(self):
|
|
self.remaining_qty = float_round(self.product_qty - sum(self.mapped('linked_move_operation_ids').mapped('qty')), precision_rounding=self.product_id.uom_id.rounding)
|
|
|
|
@api.one
|
|
@api.depends('state', 'quant_ids.lot_id', 'reserved_quant_ids.lot_id')
|
|
def _compute_lot_ids(self):
|
|
if self.state == 'done':
|
|
self.lot_ids = self.mapped('quant_ids').mapped('lot_id').ids
|
|
else:
|
|
self.lot_ids = self.mapped('reserved_quant_ids').mapped('lot_id').ids
|
|
|
|
@api.multi
|
|
@api.depends('reserved_quant_ids.qty')
|
|
def _compute_reserved_availability(self):
|
|
result = {data['reservation_id'][0]: data['qty'] for data in
|
|
self.env['stock.quant'].read_group([('reservation_id', 'in', self.ids)], ['reservation_id','qty'], ['reservation_id'])}
|
|
for rec in self:
|
|
rec.reserved_availability = result.get(rec.id, 0.0)
|
|
|
|
@api.one
|
|
@api.depends('state', 'product_id', 'product_qty', 'location_id')
|
|
def _compute_product_availability(self):
|
|
if self.state == 'done':
|
|
self.availability = self.product_qty
|
|
else:
|
|
quants = self.env['stock.quant'].search([('location_id', 'child_of', self.location_id.id), ('product_id', '=', self.product_id.id), ('reservation_id', '=', False)])
|
|
self.availability = min(self.product_qty, sum(quants.mapped('qty')))
|
|
|
|
@api.multi
|
|
def _compute_string_qty_information(self):
|
|
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
|
void_moves = self.filtered(lambda move: move.state in ('draft', 'done', 'cancel') or move.location_id.usage != 'internal')
|
|
other_moves = self - void_moves
|
|
for move in void_moves:
|
|
move.string_availability_info = '' # 'not applicable' or 'n/a' could work too
|
|
for move in other_moves:
|
|
total_available = min(move.product_qty, move.reserved_availability + move.availability)
|
|
total_available = move.product_id.uom_id._compute_quantity(total_available, move.product_uom, round=False)
|
|
total_available = float_round(total_available, precision_digits=precision)
|
|
info = str(total_available)
|
|
if self.user_has_groups('product.group_uom'):
|
|
info += ' ' + move.product_uom.name
|
|
if move.reserved_availability:
|
|
if move.reserved_availability != total_available:
|
|
# some of the available quantity is assigned and some are available but not reserved
|
|
reserved_available = move.product_id.uom_id._compute_quantity(move.reserved_availability, move.product_uom, round=False)
|
|
reserved_available = float_round(reserved_available, precision_digits=precision)
|
|
info += _(' (%s reserved)') % str(reserved_available)
|
|
else:
|
|
# all available quantity is assigned
|
|
info += _(' (reserved)')
|
|
move.string_availability_info = info
|
|
|
|
@api.constrains('product_uom')
|
|
def _check_uom(self):
|
|
moves_error = self.filtered(lambda move: move.product_id.uom_id.category_id != move.product_uom.category_id)
|
|
if moves_error:
|
|
user_warning = _('You try to move a product using a UoM that is not compatible with the UoM of the product moved. Please use an UoM in the same UoM category.')
|
|
for move in moves_error:
|
|
user_warning += _('\n\n%s --> Product UoM is %s (%s) - Move UoM is %s (%s)') % (move.product_id.display_name, move.product_id.uom_id.name, move.product_id.uom_id.category_id.name, move.product_uom.name, move.product_uom.category_id.name)
|
|
user_warning += _('\n\nBlocking: %s') % ' ,'.join(moves_error.mapped('name'))
|
|
raise UserError(user_warning)
|
|
|
|
@api.model_cr
|
|
def init(self):
|
|
self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('stock_move_product_location_index',))
|
|
if not self._cr.fetchone():
|
|
self._cr.execute('CREATE INDEX stock_move_product_location_index ON stock_move (product_id, location_id, location_dest_id, company_id, state)')
|
|
|
|
@api.multi
|
|
def name_get(self):
|
|
res = []
|
|
for move in self:
|
|
res.append((move.id, '%s%s%s>%s' % (
|
|
move.picking_id.origin and '%s/' % move.picking_id.origin or '',
|
|
move.product_id.code and '%s: ' % move.product_id.code or '',
|
|
move.location_id.name, move.location_dest_id.name)))
|
|
return res
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
# TDE CLEANME: why doing this tracking on picking here ? seems weird
|
|
perform_tracking = not self.env.context.get('mail_notrack') and vals.get('picking_id')
|
|
if perform_tracking:
|
|
picking = self.env['stock.picking'].browse(vals['picking_id'])
|
|
initial_values = {picking.id: {'state': picking.state}}
|
|
vals['ordered_qty'] = vals.get('product_uom_qty')
|
|
res = super(StockMove, self).create(vals)
|
|
if perform_tracking:
|
|
picking.message_track(picking.fields_get(['state']), initial_values)
|
|
return res
|
|
|
|
@api.multi
|
|
def write(self, vals):
|
|
# TDE CLEANME: it is a gros bordel + tracking
|
|
Picking = self.env['stock.picking']
|
|
# Check that we do not modify a stock.move which is done
|
|
frozen_fields = ['product_qty', 'product_uom', 'location_id', 'location_dest_id', 'product_id']
|
|
if any(fname in frozen_fields for fname in vals.keys()) and any(move.state == 'done' for move in self):
|
|
raise UserError(_('Quantities, Units of Measure, Products and Locations cannot be modified on stock moves that have already been processed (except by the Administrator).'))
|
|
|
|
propagated_changes_dict = {}
|
|
#propagation of expected date:
|
|
propagated_date_field = False
|
|
if vals.get('date_expected'):
|
|
#propagate any manual change of the expected date
|
|
propagated_date_field = 'date_expected'
|
|
elif (vals.get('state', '') == 'done' and vals.get('date')):
|
|
#propagate also any delta observed when setting the move as done
|
|
propagated_date_field = 'date'
|
|
|
|
if not self._context.get('do_not_propagate', False) and (propagated_date_field or propagated_changes_dict):
|
|
#any propagation is (maybe) needed
|
|
for move in self:
|
|
if move.move_dest_id and move.propagate:
|
|
if 'date_expected' in propagated_changes_dict:
|
|
propagated_changes_dict.pop('date_expected')
|
|
if propagated_date_field:
|
|
current_date = datetime.strptime(move.date_expected, DEFAULT_SERVER_DATETIME_FORMAT)
|
|
new_date = datetime.strptime(vals.get(propagated_date_field), DEFAULT_SERVER_DATETIME_FORMAT)
|
|
delta_days = (new_date - current_date).total_seconds() / 86400
|
|
if abs(delta_days) >= move.company_id.propagation_minimum_delta:
|
|
old_move_date = datetime.strptime(move.move_dest_id.date_expected, DEFAULT_SERVER_DATETIME_FORMAT)
|
|
new_move_date = (old_move_date + relativedelta.relativedelta(days=delta_days or 0)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
|
propagated_changes_dict['date_expected'] = new_move_date
|
|
#For pushed moves as well as for pulled moves, propagate by recursive call of write().
|
|
#Note that, for pulled moves we intentionally don't propagate on the procurement.
|
|
if propagated_changes_dict:
|
|
move.move_dest_id.write(propagated_changes_dict)
|
|
track_pickings = not self._context.get('mail_notrack') and any(field in vals for field in ['state', 'picking_id', 'partially_available'])
|
|
if track_pickings:
|
|
to_track_picking_ids = set([move.picking_id.id for move in self if move.picking_id])
|
|
if vals.get('picking_id'):
|
|
to_track_picking_ids.add(vals['picking_id'])
|
|
to_track_picking_ids = list(to_track_picking_ids)
|
|
pickings = Picking.browse(to_track_picking_ids)
|
|
initial_values = dict((picking.id, {'state': picking.state}) for picking in pickings)
|
|
res = super(StockMove, self).write(vals)
|
|
if track_pickings:
|
|
pickings.message_track(pickings.fields_get(['state']), initial_values)
|
|
return res
|
|
|
|
# Misc tools
|
|
# ------------------------------------------------------------
|
|
|
|
def get_price_unit(self):
|
|
""" Returns the unit price to store on the quant """
|
|
return self.price_unit or self.product_id.standard_price
|
|
|
|
def get_removal_strategy(self):
|
|
''' Returns the removal strategy to consider for the given move/ops '''
|
|
if self.product_id.categ_id.removal_strategy_id:
|
|
return self.product_id.categ_id.removal_strategy_id.method
|
|
loc = self.location_id
|
|
while loc:
|
|
if loc.removal_strategy_id:
|
|
return loc.removal_strategy_id.method
|
|
loc = loc.location_id
|
|
return 'fifo'
|
|
|
|
@api.returns('self')
|
|
@api.multi # TDE: DECORATOR to remove
|
|
def get_ancestors(self):
|
|
'''Find the first level ancestors of given move '''
|
|
ancestors = self.env['stock.move']
|
|
move = self
|
|
while move:
|
|
ancestors |= move.move_orig_ids
|
|
move = not move.move_orig_ids and move.split_from or False
|
|
return ancestors
|
|
find_move_ancestors = get_ancestors
|
|
|
|
def _filter_closed_moves(self):
|
|
""" Helper methods when having to avoid working on moves that are
|
|
already done or canceled. In a lot of cases you may handle a batch
|
|
of stock moves, some being already done / canceled, other being still
|
|
under computation. Instead of having to use filtered everywhere and
|
|
forgot some of them, use this tool instead. """
|
|
return self.filtered(lambda move: move.state not in ('done', 'cancel'))
|
|
|
|
|
|
# Main actions
|
|
# ------------------------------------------------------------
|
|
|
|
@api.multi
|
|
def do_unreserve(self):
|
|
if any(move.state in ('done', 'cancel') for move in self):
|
|
raise UserError(_('Cannot unreserve a done move'))
|
|
self.quants_unreserve()
|
|
if not self.env.context.get('no_state_change'):
|
|
waiting = self.filtered(lambda move: move.procure_method == 'make_to_order' or move.get_ancestors())
|
|
waiting.write({'state': 'waiting'})
|
|
(self - waiting).write({'state': 'confirmed'})
|
|
|
|
def _push_apply(self):
|
|
# TDE CLEANME: I am quite sure I already saw this code somewhere ... in routing ??
|
|
Push = self.env['stock.location.path']
|
|
for move in self:
|
|
# if the move is already chained, there is no need to check push rules
|
|
if move.move_dest_id:
|
|
continue
|
|
# if the move is a returned move, we don't want to check push rules, as returning a returned move is the only decent way
|
|
# to receive goods without triggering the push rules again (which would duplicate chained operations)
|
|
domain = [('location_from_id', '=', move.location_dest_id.id)]
|
|
# priority goes to the route defined on the product and product category
|
|
routes = move.product_id.route_ids | move.product_id.categ_id.total_route_ids
|
|
rules = Push.search(domain + [('route_id', 'in', routes.ids)], order='route_sequence, sequence', limit=1)
|
|
if not rules:
|
|
# TDE FIXME/ should those really be in a if / elif ??
|
|
# then we search on the warehouse if a rule can apply
|
|
if move.warehouse_id:
|
|
rules = Push.search(domain + [('route_id', 'in', move.warehouse_id.route_ids.ids)], order='route_sequence, sequence', limit=1)
|
|
elif move.picking_id.picking_type_id.warehouse_id:
|
|
rules = Push.search(domain + [('route_id', 'in', move.picking_id.picking_type_id.warehouse_id.route_ids.ids)], order='route_sequence, sequence', limit=1)
|
|
if not rules:
|
|
# if no specialized push rule has been found yet, we try to find a general one (without route)
|
|
rules = Push.search(domain + [('route_id', '=', False)], order='sequence', limit=1)
|
|
# Make sure it is not returning the return
|
|
if rules and (not move.origin_returned_move_id or move.origin_returned_move_id.location_dest_id.id != rules.location_dest_id.id):
|
|
rules._apply(move)
|
|
return True
|
|
|
|
@api.onchange('product_id', 'product_qty')
|
|
def onchange_quantity(self):
|
|
if not self.product_id or self.product_qty < 0.0:
|
|
self.product_qty = 0.0
|
|
if self.product_qty < self._origin.product_qty:
|
|
warning_mess = {
|
|
'title': _('Quantity decreased!'),
|
|
'message' : _("By changing this quantity here, you accept the "
|
|
"new quantity as complete: Odoo will not "
|
|
"automatically generate a back order."),
|
|
}
|
|
return {'warning': warning_mess}
|
|
|
|
@api.onchange('product_id')
|
|
def onchange_product_id(self):
|
|
product = self.product_id.with_context(lang=self.partner_id.lang or self.env.user.lang)
|
|
self.name = product.partner_ref
|
|
self.product_uom = product.uom_id.id
|
|
self.product_uom_qty = 1.0
|
|
return {'domain': {'product_uom': [('category_id', '=', product.uom_id.category_id.id)]}}
|
|
|
|
@api.onchange('date_expected')
|
|
def onchange_date(self):
|
|
if self.date_expected:
|
|
self.date = self.date_expected
|
|
|
|
# TDE DECORATOR: remove that api.multi when action_confirm is migrated
|
|
@api.multi
|
|
def assign_picking(self):
|
|
""" Try to assign the moves to an existing picking that has not been
|
|
reserved yet and has the same procurement group, locations and picking
|
|
type (moves should already have them identical). Otherwise, create a new
|
|
picking to assign them to. """
|
|
Picking = self.env['stock.picking']
|
|
for move in self:
|
|
recompute = False
|
|
picking = Picking.search([
|
|
('group_id', '=', move.group_id.id),
|
|
('location_id', '=', move.location_id.id),
|
|
('location_dest_id', '=', move.location_dest_id.id),
|
|
('picking_type_id', '=', move.picking_type_id.id),
|
|
('printed', '=', False),
|
|
('state', 'in', ['draft', 'confirmed', 'waiting', 'partially_available', 'assigned'])], limit=1)
|
|
if not picking:
|
|
recompute = True
|
|
picking = Picking.create(move._get_new_picking_values())
|
|
move.write({'picking_id': picking.id})
|
|
|
|
# If this method is called in batch by a write on a one2many and
|
|
# at some point had to create a picking, some next iterations could
|
|
# try to find back the created picking. As we look for it by searching
|
|
# on some computed fields, we have to force a recompute, else the
|
|
# record won't be found.
|
|
if recompute:
|
|
move.recompute()
|
|
return True
|
|
_picking_assign = assign_picking
|
|
|
|
def _get_new_picking_values(self):
|
|
""" Prepares a new picking for this move as it could not be assigned to
|
|
another picking. This method is designed to be inherited. """
|
|
return {
|
|
'origin': self.origin,
|
|
'company_id': self.company_id.id,
|
|
'move_type': self.group_id and self.group_id.move_type or 'direct',
|
|
'partner_id': self.partner_id.id,
|
|
'picking_type_id': self.picking_type_id.id,
|
|
'location_id': self.location_id.id,
|
|
'location_dest_id': self.location_dest_id.id,
|
|
}
|
|
_prepare_picking_assign = _get_new_picking_values
|
|
|
|
@api.multi
|
|
def action_confirm(self):
|
|
""" Confirms stock move or put it in waiting if it's linked to another move. """
|
|
move_create_proc = self.env['stock.move']
|
|
move_to_confirm = self.env['stock.move']
|
|
move_waiting = self.env['stock.move']
|
|
|
|
to_assign = {}
|
|
self.set_default_price_unit_from_product()
|
|
for move in self:
|
|
# if the move is preceeded, then it's waiting (if preceeding move is done, then action_assign has been called already and its state is already available)
|
|
if move.move_orig_ids:
|
|
move_waiting |= move
|
|
# if the move is split and some of the ancestor was preceeded, then it's waiting as well
|
|
else:
|
|
inner_move = move.split_from
|
|
while inner_move:
|
|
if inner_move.move_orig_ids:
|
|
move_waiting |= move
|
|
break
|
|
inner_move = inner_move.split_from
|
|
else:
|
|
if move.procure_method == 'make_to_order':
|
|
move_create_proc |= move
|
|
else:
|
|
move_to_confirm |= move
|
|
|
|
if not move.picking_id and move.picking_type_id:
|
|
key = (move.group_id.id, move.location_id.id, move.location_dest_id.id)
|
|
if key not in to_assign:
|
|
to_assign[key] = self.env['stock.move']
|
|
to_assign[key] |= move
|
|
|
|
# create procurements for make to order moves
|
|
procurements = self.env['procurement.order']
|
|
for move in move_create_proc:
|
|
procurements |= procurements.create(move._prepare_procurement_from_move())
|
|
if procurements:
|
|
procurements.run()
|
|
|
|
move_to_confirm.write({'state': 'confirmed'})
|
|
(move_waiting | move_create_proc).write({'state': 'waiting'})
|
|
|
|
# assign picking in batch for all confirmed move that share the same details
|
|
for key, moves in to_assign.items():
|
|
moves.assign_picking()
|
|
self._push_apply()
|
|
return self
|
|
|
|
def _set_default_price_moves(self):
|
|
return self.filtered(lambda move: not move.price_unit)
|
|
|
|
def set_default_price_unit_from_product(self):
|
|
""" Set price to move, important in inter-company moves or receipts with only one partner """
|
|
for move in self._set_default_price_moves():
|
|
move.write({'price_unit': move.product_id.standard_price})
|
|
attribute_price = set_default_price_unit_from_product
|
|
|
|
def _prepare_procurement_from_move(self):
|
|
origin = (self.group_id and (self.group_id.name + ":") or "") + (self.rule_id and self.rule_id.name or self.origin or self.picking_id.name or "/")
|
|
group_id = self.group_id and self.group_id.id or False
|
|
if self.rule_id:
|
|
if self.rule_id.group_propagation_option == 'fixed' and self.rule_id.group_id:
|
|
group_id = self.rule_id.group_id.id
|
|
elif self.rule_id.group_propagation_option == 'none':
|
|
group_id = False
|
|
return {
|
|
'name': self.rule_id and self.rule_id.name or "/",
|
|
'origin': origin,
|
|
'company_id': self.company_id.id,
|
|
'date_planned': self.date,
|
|
'product_id': self.product_id.id,
|
|
'product_qty': self.product_uom_qty,
|
|
'product_uom': self.product_uom.id,
|
|
'location_id': self.location_id.id,
|
|
'move_dest_id': self.id,
|
|
'group_id': group_id,
|
|
'route_ids': [(4, x.id) for x in self.route_ids],
|
|
'warehouse_id': self.warehouse_id.id or (self.picking_type_id and self.picking_type_id.warehouse_id.id or False),
|
|
'priority': self.priority,
|
|
}
|
|
|
|
@api.multi
|
|
def force_assign(self):
|
|
# TDE CLEANME: removed return value
|
|
self.write({'state': 'assigned'})
|
|
self.check_recompute_pack_op()
|
|
|
|
# TDE DECORATOR: internal
|
|
@api.multi
|
|
def check_recompute_pack_op(self):
|
|
pickings = self.mapped('picking_id').filtered(lambda picking: picking.state not in ('waiting', 'confirmed')) # In case of 'all at once' delivery method it should not prepare pack operations
|
|
# Check if someone was treating the picking already
|
|
pickings_partial = pickings.filtered(lambda picking: not any(operation.qty_done for operation in picking.pack_operation_ids))
|
|
pickings_partial.do_prepare_partial()
|
|
(pickings - pickings_partial).write({'recompute_pack_op': True})
|
|
|
|
@api.multi
|
|
def check_tracking(self, pack_operation):
|
|
""" Checks if serial number is assigned to stock move or not and raise an error if it had to. """
|
|
# TDE FIXME: I cannot able to understand
|
|
for move in self:
|
|
if move.picking_id and \
|
|
(move.picking_id.picking_type_id.use_existing_lots or move.picking_id.picking_type_id.use_create_lots) and \
|
|
move.product_id.tracking != 'none' and \
|
|
not (move.restrict_lot_id or (pack_operation and (pack_operation.product_id and pack_operation.pack_lot_ids)) or (pack_operation and not pack_operation.product_id)):
|
|
raise UserError(_('You need to provide a Lot/Serial Number for product %s') % ("%s (%s)" % (move.product_id.name, move.picking_id.name)))
|
|
|
|
@api.multi
|
|
def action_assign(self, no_prepare=False):
|
|
""" Checks the product type and accordingly writes the state. """
|
|
# TDE FIXME: remove decorator once everything is migrated
|
|
# TDE FIXME: clean me, please
|
|
main_domain = {}
|
|
|
|
Quant = self.env['stock.quant']
|
|
Uom = self.env['product.uom']
|
|
moves_to_assign = self.env['stock.move']
|
|
moves_to_do = self.env['stock.move']
|
|
operations = self.env['stock.pack.operation']
|
|
ancestors_list = {}
|
|
|
|
# work only on in progress moves
|
|
moves = self.filtered(lambda move: move.state in ['confirmed', 'waiting', 'assigned'])
|
|
moves.filtered(lambda move: move.reserved_quant_ids).do_unreserve()
|
|
for move in moves:
|
|
if move.location_id.usage in ('supplier', 'inventory', 'production'):
|
|
moves_to_assign |= move
|
|
# TDE FIXME: what ?
|
|
# in case the move is returned, we want to try to find quants before forcing the assignment
|
|
if not move.origin_returned_move_id:
|
|
continue
|
|
# if the move is preceeded, restrict the choice of quants in the ones moved previously in original move
|
|
ancestors = move.find_move_ancestors()
|
|
if move.product_id.type == 'consu' and not ancestors:
|
|
moves_to_assign |= move
|
|
continue
|
|
else:
|
|
moves_to_do |= move
|
|
|
|
# we always search for yet unassigned quants
|
|
main_domain[move.id] = [('reservation_id', '=', False), ('qty', '>', 0)]
|
|
|
|
ancestors_list[move.id] = True if ancestors else False
|
|
if move.state == 'waiting' and not ancestors:
|
|
# if the waiting move hasn't yet any ancestor (PO/MO not confirmed yet), don't find any quant available in stock
|
|
main_domain[move.id] += [('id', '=', False)]
|
|
elif ancestors:
|
|
main_domain[move.id] += [('history_ids', 'in', ancestors.ids)]
|
|
|
|
# if the move is returned from another, restrict the choice of quants to the ones that follow the returned move
|
|
if move.origin_returned_move_id:
|
|
main_domain[move.id] += [('history_ids', 'in', move.origin_returned_move_id.id)]
|
|
for link in move.linked_move_operation_ids:
|
|
operations |= link.operation_id
|
|
|
|
# Check all ops and sort them: we want to process first the packages, then operations with lot then the rest
|
|
operations = operations.sorted(key=lambda x: ((x.package_id and not x.product_id) and -4 or 0) + (x.package_id and -2 or 0) + (x.pack_lot_ids and -1 or 0))
|
|
for ops in operations:
|
|
# TDE FIXME: this code seems to be in action_done, isn't it ?
|
|
# first try to find quants based on specific domains given by linked operations for the case where we want to rereserve according to existing pack operations
|
|
if not (ops.product_id and ops.pack_lot_ids):
|
|
for record in ops.linked_move_operation_ids:
|
|
move = record.move_id
|
|
if move.id in main_domain:
|
|
qty = record.qty
|
|
domain = main_domain[move.id]
|
|
if qty:
|
|
quants = Quant.quants_get_preferred_domain(qty, move, ops=ops, domain=domain, preferred_domain_list=[])
|
|
Quant.quants_reserve(quants, move, record)
|
|
else:
|
|
lot_qty = {}
|
|
rounding = ops.product_id.uom_id.rounding
|
|
for pack_lot in ops.pack_lot_ids:
|
|
lot_qty[pack_lot.lot_id.id] = ops.product_uom_id._compute_quantity(pack_lot.qty, ops.product_id.uom_id)
|
|
for record in ops.linked_move_operation_ids:
|
|
move_qty = record.qty
|
|
move = record.move_id
|
|
domain = main_domain[move.id]
|
|
for lot in lot_qty:
|
|
if float_compare(lot_qty[lot], 0, precision_rounding=rounding) > 0 and float_compare(move_qty, 0, precision_rounding=rounding) > 0:
|
|
qty = min(lot_qty[lot], move_qty)
|
|
quants = Quant.quants_get_preferred_domain(qty, move, ops=ops, lot_id=lot, domain=domain, preferred_domain_list=[])
|
|
Quant.quants_reserve(quants, move, record)
|
|
lot_qty[lot] -= qty
|
|
move_qty -= qty
|
|
|
|
# Sort moves to reserve first the ones with ancestors, in case the same product is listed in
|
|
# different stock moves.
|
|
for move in sorted(moves_to_do, key=lambda x: -1 if ancestors_list.get(x.id) else 0):
|
|
# then if the move isn't totally assigned, try to find quants without any specific domain
|
|
if move.state != 'assigned' and not self.env.context.get('reserve_only_ops'):
|
|
qty_already_assigned = move.reserved_availability
|
|
qty = move.product_qty - qty_already_assigned
|
|
|
|
quants = Quant.quants_get_preferred_domain(qty, move, domain=main_domain[move.id], preferred_domain_list=[])
|
|
Quant.quants_reserve(quants, move)
|
|
|
|
# force assignation of consumable products and incoming from supplier/inventory/production
|
|
# Do not take force_assign as it would create pack operations
|
|
if moves_to_assign:
|
|
moves_to_assign.write({'state': 'assigned'})
|
|
if not no_prepare:
|
|
self.check_recompute_pack_op()
|
|
|
|
def _propagate_cancel(self):
|
|
self.ensure_one()
|
|
if self.move_dest_id:
|
|
if self.propagate:
|
|
if self.move_dest_id.state not in ('done', 'cancel'):
|
|
self.move_dest_id.action_cancel()
|
|
elif self.move_dest_id.state == 'waiting':
|
|
# If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead)
|
|
self.move_dest_id.write({'state': 'confirmed'})
|
|
|
|
@api.multi
|
|
def action_cancel(self):
|
|
""" Cancels the moves and if all moves are cancelled it cancels the picking. """
|
|
# TDE DUMB: why is cancel_procuremetn in ctx we do quite nothing ?? like not updating the move ??
|
|
if any(move.state == 'done' for move in self):
|
|
raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.'))
|
|
|
|
procurements = self.env['procurement.order']
|
|
for move in self:
|
|
if move.reserved_quant_ids:
|
|
move.quants_unreserve()
|
|
if self.env.context.get('cancel_procurement'):
|
|
if move.propagate:
|
|
pass
|
|
# procurements.search([('move_dest_id', '=', move.id)]).cancel()
|
|
else:
|
|
move._propagate_cancel()
|
|
if move.procurement_id:
|
|
procurements |= move.procurement_id
|
|
|
|
self.write({'state': 'cancel', 'move_dest_id': False})
|
|
if procurements:
|
|
procurements.check()
|
|
return True
|
|
|
|
def recalculate_move_state(self):
|
|
'''Recompute the state of moves given because their reserved quants were used to fulfill another operation'''
|
|
# TDE FIXME: what is the real purpose of this ? probably clean me
|
|
for move in self:
|
|
vals = {}
|
|
reserved_quant_ids = move.reserved_quant_ids
|
|
if len(reserved_quant_ids) > 0 and not move.partially_available:
|
|
vals['partially_available'] = True
|
|
if len(reserved_quant_ids) == 0 and move.partially_available:
|
|
vals['partially_available'] = False
|
|
if move.state == 'assigned':
|
|
if move.procure_method == 'make_to_order' or move.find_move_ancestors():
|
|
vals['state'] = 'waiting'
|
|
else:
|
|
vals['state'] = 'confirmed'
|
|
if vals:
|
|
move.write(vals)
|
|
|
|
@api.model
|
|
def _move_quants_by_lot(self, ops, lot_qty, quants_taken, false_quants, lot_move_qty, quant_dest_package_id):
|
|
"""
|
|
This function is used to process all the pack operation lots of a pack operation
|
|
For every move:
|
|
First, we check the quants with lot already reserved (and those are already subtracted from the lots to do)
|
|
Then go through all the lots to process:
|
|
Add reserved false lots lot by lot
|
|
Check if there are not reserved quants or reserved elsewhere with that lot or without lot (with the traditional method)
|
|
"""
|
|
return self.browse(lot_move_qty.keys())._move_quants_by_lot_v10(quants_taken, false_quants, ops, lot_qty, lot_move_qty, quant_dest_package_id)
|
|
|
|
@api.multi
|
|
def _move_quants_by_lot_v10(self, quants_taken, false_quants, pack_operation, lot_quantities, lot_move_quantities, dest_package_id):
|
|
Quant = self.env['stock.quant']
|
|
rounding = pack_operation.product_id.uom_id.rounding
|
|
preferred_domain_list = [[('reservation_id', '=', False)], ['&', ('reservation_id', 'not in', self.ids), ('reservation_id', '!=', False)]]
|
|
|
|
for move_rec_updateme in self:
|
|
from collections import defaultdict
|
|
lot_to_quants = defaultdict(list)
|
|
|
|
# Assign quants already reserved with lot to the correct
|
|
for quant in quants_taken:
|
|
if quant[0] <= move_rec_updateme.reserved_quant_ids:
|
|
lot_to_quants[quant[0].lot_id.id].append(quant)
|
|
|
|
false_quants_move = [x for x in false_quants if x[0].reservation_id.id == move_rec_updateme.id]
|
|
for lot_id in lot_quantities.keys():
|
|
redo_false_quants = False
|
|
|
|
# Take remaining reserved quants with no lot first
|
|
# (This will be used mainly when incoming had no lot and you do outgoing with)
|
|
while false_quants_move and float_compare(lot_quantities[lot_id], 0, precision_rounding=rounding) > 0 and float_compare(lot_move_quantities[move_rec_updateme.id], 0, precision_rounding=rounding) > 0:
|
|
qty_min = min(lot_quantities[lot_id], lot_move_quantities[move_rec_updateme.id])
|
|
if false_quants_move[0].qty > qty_min:
|
|
lot_to_quants[lot_id] += [(false_quants_move[0], qty_min)]
|
|
qty = qty_min
|
|
redo_false_quants = True
|
|
else:
|
|
qty = false_quants_move[0].qty
|
|
lot_to_quants[lot_id] += [(false_quants_move[0], qty)]
|
|
false_quants_move.pop(0)
|
|
lot_quantities[lot_id] -= qty
|
|
lot_move_quantities[move_rec_updateme.id] -= qty
|
|
|
|
# Search other with first matching lots and then without lots
|
|
if float_compare(lot_move_quantities[move_rec_updateme.id], 0, precision_rounding=rounding) > 0 and float_compare(lot_quantities[lot_id], 0, precision_rounding=rounding) > 0:
|
|
# Search if we can find quants with that lot
|
|
qty = min(lot_quantities[lot_id], lot_move_quantities[move_rec_updateme.id])
|
|
quants = Quant.quants_get_preferred_domain(
|
|
qty, move_rec_updateme, ops=pack_operation, lot_id=lot_id, domain=[('qty', '>', 0)],
|
|
preferred_domain_list=preferred_domain_list)
|
|
lot_to_quants[lot_id] += quants
|
|
lot_quantities[lot_id] -= qty
|
|
lot_move_quantities[move_rec_updateme.id] -= qty
|
|
|
|
# Move all the quants related to that lot/move
|
|
if lot_to_quants[lot_id]:
|
|
Quant.quants_move(
|
|
lot_to_quants[lot_id], move_rec_updateme, pack_operation.location_dest_id,
|
|
location_from=pack_operation.location_id, lot_id=lot_id,
|
|
owner_id=pack_operation.owner_id.id, src_package_id=pack_operation.package_id.id,
|
|
dest_package_id=dest_package_id)
|
|
if redo_false_quants:
|
|
false_quants_move = [x for x in move_rec_updateme.reserved_quant_ids if (not x.lot_id) and (x.owner_id.id == pack_operation.owner_id.id) and
|
|
(x.location_id.id == pack_operation.location_id.id) and (x.package_id.id == pack_operation.package_id.id)]
|
|
return True
|
|
|
|
@api.multi
|
|
def action_done(self):
|
|
""" Process completely the moves given and if all moves are done, it will finish the picking. """
|
|
self.filtered(lambda move: move.state == 'draft').action_confirm()
|
|
|
|
Uom = self.env['product.uom']
|
|
Quant = self.env['stock.quant']
|
|
|
|
pickings = self.env['stock.picking']
|
|
procurements = self.env['procurement.order']
|
|
operations = self.env['stock.pack.operation']
|
|
|
|
remaining_move_qty = {}
|
|
|
|
for move in self:
|
|
if move.picking_id:
|
|
pickings |= move.picking_id
|
|
remaining_move_qty[move.id] = move.product_qty
|
|
for link in move.linked_move_operation_ids:
|
|
operations |= link.operation_id
|
|
pickings |= link.operation_id.picking_id
|
|
|
|
# Sort operations according to entire packages first, then package + lot, package only, lot only
|
|
operations = operations.sorted(key=lambda x: ((x.package_id and not x.product_id) and -4 or 0) + (x.package_id and -2 or 0) + (x.pack_lot_ids and -1 or 0))
|
|
|
|
for operation in operations:
|
|
|
|
# product given: result put immediately in the result package (if False: without package)
|
|
# but if pack moved entirely, quants should not be written anything for the destination package
|
|
quant_dest_package_id = operation.product_id and operation.result_package_id.id or False
|
|
entire_pack = not operation.product_id and True or False
|
|
|
|
# compute quantities for each lot + check quantities match
|
|
lot_quantities = dict((pack_lot.lot_id.id, operation.product_uom_id._compute_quantity(pack_lot.qty, operation.product_id.uom_id)
|
|
) for pack_lot in operation.pack_lot_ids)
|
|
|
|
qty = operation.product_qty
|
|
if operation.product_uom_id and operation.product_uom_id != operation.product_id.uom_id:
|
|
qty = operation.product_uom_id._compute_quantity(qty, operation.product_id.uom_id)
|
|
if operation.pack_lot_ids and float_compare(sum(lot_quantities.values()), qty, precision_rounding=operation.product_id.uom_id.rounding) != 0.0:
|
|
raise UserError(_('You have a difference between the quantity on the operation and the quantities specified for the lots. '))
|
|
|
|
quants_taken = []
|
|
false_quants = []
|
|
lot_move_qty = {}
|
|
|
|
prout_move_qty = {}
|
|
for link in operation.linked_move_operation_ids:
|
|
prout_move_qty[link.move_id] = prout_move_qty.get(link.move_id, 0.0) + link.qty
|
|
|
|
# Process every move only once for every pack operation
|
|
for move in prout_move_qty.keys():
|
|
# TDE FIXME: do in batch ?
|
|
move.check_tracking(operation)
|
|
|
|
# TDE FIXME: I bet the message error is wrong
|
|
if not remaining_move_qty.get(move.id):
|
|
raise UserError(_("The roundings of your unit of measure %s on the move vs. %s on the product don't allow to do these operations or you are not transferring the picking at once. ") % (move.product_uom.name, move.product_id.uom_id.name))
|
|
|
|
if not operation.pack_lot_ids:
|
|
preferred_domain_list = [[('reservation_id', '=', move.id)], [('reservation_id', '=', False)], ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)]]
|
|
quants = Quant.quants_get_preferred_domain(
|
|
prout_move_qty[move], move, ops=operation, domain=[('qty', '>', 0)],
|
|
preferred_domain_list=preferred_domain_list)
|
|
Quant.quants_move(quants, move, operation.location_dest_id, location_from=operation.location_id,
|
|
lot_id=False, owner_id=operation.owner_id.id, src_package_id=operation.package_id.id,
|
|
dest_package_id=quant_dest_package_id, entire_pack=entire_pack)
|
|
else:
|
|
# Check what you can do with reserved quants already
|
|
qty_on_link = prout_move_qty[move]
|
|
rounding = operation.product_id.uom_id.rounding
|
|
for reserved_quant in move.reserved_quant_ids:
|
|
if (reserved_quant.owner_id.id != operation.owner_id.id) or (reserved_quant.location_id.id != operation.location_id.id) or \
|
|
(reserved_quant.package_id.id != operation.package_id.id):
|
|
continue
|
|
if not reserved_quant.lot_id:
|
|
false_quants += [reserved_quant]
|
|
elif float_compare(lot_quantities.get(reserved_quant.lot_id.id, 0), 0, precision_rounding=rounding) > 0:
|
|
if float_compare(lot_quantities[reserved_quant.lot_id.id], reserved_quant.qty, precision_rounding=rounding) >= 0:
|
|
lot_quantities[reserved_quant.lot_id.id] -= reserved_quant.qty
|
|
quants_taken += [(reserved_quant, reserved_quant.qty)]
|
|
qty_on_link -= reserved_quant.qty
|
|
else:
|
|
quants_taken += [(reserved_quant, lot_quantities[reserved_quant.lot_id.id])]
|
|
lot_quantities[reserved_quant.lot_id.id] = 0
|
|
qty_on_link -= lot_quantities[reserved_quant.lot_id.id]
|
|
lot_move_qty[move.id] = qty_on_link
|
|
|
|
remaining_move_qty[move.id] -= prout_move_qty[move]
|
|
|
|
# Handle lots separately
|
|
if operation.pack_lot_ids:
|
|
# TDE FIXME: fix call to move_quants_by_lot to ease understanding
|
|
self._move_quants_by_lot(operation, lot_quantities, quants_taken, false_quants, lot_move_qty, quant_dest_package_id)
|
|
|
|
# Handle pack in pack
|
|
if not operation.product_id and operation.package_id and operation.result_package_id.id != operation.package_id.parent_id.id:
|
|
operation.package_id.sudo().write({'parent_id': operation.result_package_id.id})
|
|
|
|
# Check for remaining qtys and unreserve/check move_dest_id in
|
|
move_dest_ids = set()
|
|
for move in self:
|
|
if float_compare(remaining_move_qty[move.id], 0, precision_rounding=move.product_id.uom_id.rounding) > 0: # In case no pack operations in picking
|
|
move.check_tracking(False) # TDE: do in batch ? redone ? check this
|
|
|
|
preferred_domain_list = [[('reservation_id', '=', move.id)], [('reservation_id', '=', False)], ['&', ('reservation_id', '!=', move.id), ('reservation_id', '!=', False)]]
|
|
quants = Quant.quants_get_preferred_domain(
|
|
remaining_move_qty[move.id], move, domain=[('qty', '>', 0)],
|
|
preferred_domain_list=preferred_domain_list)
|
|
Quant.quants_move(
|
|
quants, move, move.location_dest_id,
|
|
lot_id=move.restrict_lot_id.id, owner_id=move.restrict_partner_id.id)
|
|
|
|
# If the move has a destination, add it to the list to reserve
|
|
if move.move_dest_id and move.move_dest_id.state in ('waiting', 'confirmed'):
|
|
move_dest_ids.add(move.move_dest_id.id)
|
|
|
|
if move.procurement_id:
|
|
procurements |= move.procurement_id
|
|
|
|
# unreserve the quants and make them available for other operations/moves
|
|
move.quants_unreserve()
|
|
|
|
# Check the packages have been placed in the correct locations
|
|
self.mapped('quant_ids').filtered(lambda quant: quant.package_id and quant.qty > 0).mapped('package_id')._check_location_constraint()
|
|
|
|
# set the move as done
|
|
self.write({'state': 'done', 'date': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
|
|
procurements.check()
|
|
# assign destination moves
|
|
if move_dest_ids:
|
|
# TDE FIXME: record setise me
|
|
self.browse(list(move_dest_ids)).action_assign()
|
|
|
|
pickings.filtered(lambda picking: picking.state == 'done' and not picking.date_done).write({'date_done': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
|
|
|
|
return True
|
|
|
|
@api.multi
|
|
def unlink(self):
|
|
if any(move.state not in ('draft', 'cancel') for move in self):
|
|
raise UserError(_('You can only delete draft moves.'))
|
|
return super(StockMove, self).unlink()
|
|
|
|
def _propagate_split(self, new_move, qty):
|
|
if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'):
|
|
new_move_prop = self.move_dest_id.split(qty)
|
|
new_move.write({'move_dest_id': new_move_prop})
|
|
|
|
@api.multi
|
|
def split(self, qty, restrict_lot_id=False, restrict_partner_id=False):
|
|
""" Splits qty from move move into a new move
|
|
|
|
:param qty: float. quantity to split (given in product UoM)
|
|
:param restrict_lot_id: optional production lot that can be given in order to force the new move to restrict its choice of quants to this lot.
|
|
:param restrict_partner_id: optional partner that can be given in order to force the new move to restrict its choice of quants to the ones belonging to this partner.
|
|
:param context: dictionay. can contains the special key 'source_location_id' in order to force the source location when copying the move
|
|
:returns: id of the backorder move created """
|
|
self = self.with_prefetch() # This makes the ORM only look for one record and not 300 at a time, which improves performance
|
|
if self.state in ('done', 'cancel'):
|
|
raise UserError(_('You cannot split a move done'))
|
|
elif self.state == 'draft':
|
|
# we restrict the split of a draft move because if not confirmed yet, it may be replaced by several other moves in
|
|
# case of phantom bom (with mrp module). And we don't want to deal with this complexity by copying the product that will explode.
|
|
raise UserError(_('You cannot split a draft move. It needs to be confirmed first.'))
|
|
if float_is_zero(qty, precision_rounding=self.product_id.uom_id.rounding) or self.product_qty <= qty:
|
|
return self.id
|
|
# HALF-UP rounding as only rounding errors will be because of propagation of error from default UoM
|
|
uom_qty = self.product_id.uom_id._compute_quantity(qty, self.product_uom, rounding_method='HALF-UP')
|
|
defaults = {
|
|
'product_uom_qty': uom_qty,
|
|
'procure_method': 'make_to_stock',
|
|
'restrict_lot_id': restrict_lot_id,
|
|
'split_from': self.id,
|
|
'procurement_id': self.procurement_id.id,
|
|
'move_dest_id': self.move_dest_id.id,
|
|
'origin_returned_move_id': self.origin_returned_move_id.id,
|
|
}
|
|
if restrict_partner_id:
|
|
defaults['restrict_partner_id'] = restrict_partner_id
|
|
|
|
# TDE CLEANME: remove context key + add as parameter
|
|
if self.env.context.get('source_location_id'):
|
|
defaults['location_id'] = self.env.context['source_location_id']
|
|
new_move = self.with_context(rounding_method='HALF-UP').copy(defaults)
|
|
# ctx = context.copy()
|
|
# TDE CLEANME: used only in write in this file, to clean
|
|
# ctx['do_not_propagate'] = True
|
|
self.with_context(do_not_propagate=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty})
|
|
self._propagate_split(new_move, qty)
|
|
# returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and
|
|
# thus the result of action_confirm should always be a list of 1 element length)
|
|
new_move = new_move.action_confirm()
|
|
# TDE FIXME: due to action confirm change
|
|
return new_move.id
|
|
|
|
@api.multi
|
|
def action_show_picking(self):
|
|
view = self.env.ref('stock.view_picking_form')
|
|
return {
|
|
'name': _('Transfer'),
|
|
'type': 'ir.actions.act_window',
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'res_model': 'stock.picking',
|
|
'views': [(view.id, 'form')],
|
|
'view_id': view.id,
|
|
'target': 'new',
|
|
'res_id': self.picking_id.id}
|
|
show_picking = action_show_picking
|
|
|
|
# Quants management
|
|
# ----------------------------------------------------------------------
|
|
|
|
def quants_unreserve(self):
|
|
self.filtered(lambda x: x.partially_available).write({'partially_available': False})
|
|
self.mapped('reserved_quant_ids').sudo().write({'reservation_id': False})
|