628 lines
31 KiB
Python
628 lines
31 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
from datetime import timedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.fields import Command, Domain
|
|
from odoo.tools.misc import groupby as tools_groupby
|
|
|
|
|
|
class SaleOrderLine(models.Model):
|
|
_inherit = 'sale.order.line'
|
|
|
|
tracking = fields.Selection(related='product_id.tracking', depends=['product_id'])
|
|
reserved_lot_ids = fields.Many2many(
|
|
'stock.lot',
|
|
'rental_reserved_lot_rel',
|
|
string="Reserved Lots",
|
|
domain="[('product_id', '=', product_id)]",
|
|
copy=False,
|
|
group_expand='_read_group_expand_reserved_lot_ids',
|
|
)
|
|
available_reserved_lots = fields.Boolean(compute='_compute_available_reserved_lots')
|
|
|
|
pickedup_lot_ids = fields.Many2many(
|
|
'stock.lot',
|
|
'rental_pickedup_lot_rel',
|
|
domain="[('product_id', '=', product_id)]",
|
|
copy=False,
|
|
)
|
|
returned_lot_ids = fields.Many2many(
|
|
'stock.lot',
|
|
'rental_returned_lot_rel',
|
|
domain="[('product_id', '=', product_id)]",
|
|
copy=False,
|
|
)
|
|
unavailable_lot_ids = fields.Many2many(
|
|
'stock.lot',
|
|
'unreturned_reserved_serial',
|
|
compute='_compute_unavailable_lots',
|
|
store=False,
|
|
)
|
|
|
|
@api.depends('reserved_lot_ids', 'reservation_begin', 'return_date')
|
|
def _compute_available_reserved_lots(self):
|
|
# A lot is available if it is currently in the stock AND it won't be removed from stock
|
|
# before the end of rental period.
|
|
# A lot is available if it is not currently in the stock AND it will be back in stock
|
|
# before the start of rental period.
|
|
self.available_reserved_lots = True
|
|
if not self._are_rental_pickings_enabled():
|
|
return
|
|
lines_to_check = self.filtered(lambda l: l.is_rental and l.reserved_lot_ids and l.product_template_id.tracking == 'serial')
|
|
|
|
partner_location_ids = self.env['stock.location'].search([('usage', 'in', ['customer', 'supplier'])])
|
|
|
|
for line in lines_to_check:
|
|
company_id = line.order_id.company_id.id
|
|
domain = [
|
|
('company_id', '=', company_id),
|
|
('state', 'not in', ['done', 'cancel']),
|
|
('lot_id', 'in', line.reserved_lot_ids.ids),
|
|
]
|
|
leaving_move_lines_groups = self.env['stock.move.line']._read_group(
|
|
Domain.AND([domain, [
|
|
('location_usage', '=', 'internal'),
|
|
('location_dest_id', 'child_of', partner_location_ids.ids),
|
|
]]),
|
|
groupby=['lot_id'],
|
|
aggregates=['id:recordset'],
|
|
)
|
|
leaving_move_by_lot = {g[0].id: g[1] for g in leaving_move_lines_groups}
|
|
incoming_move_lines_groups = self.env['stock.move.line']._read_group(
|
|
Domain.AND([domain, [
|
|
('location_id', 'child_of', partner_location_ids.ids),
|
|
('location_dest_usage', '=', 'internal'),
|
|
]]),
|
|
groupby=['lot_id'],
|
|
aggregates=['id:recordset'],
|
|
)
|
|
incoming_move_by_lot = {g[0].id: g[1] for g in incoming_move_lines_groups}
|
|
for lot in line.reserved_lot_ids:
|
|
lot_id = lot.ids[0]
|
|
if lot_id in line.move_ids.lot_ids.ids:
|
|
continue
|
|
in_stock = bool(sum(
|
|
lot.quant_ids.filtered(
|
|
lambda q: q.location_id.usage in ['internal', 'transit']
|
|
and q.location_id not in partner_location_ids.child_internal_location_ids
|
|
and q.company_id.id == company_id).mapped('quantity')
|
|
))
|
|
if in_stock:
|
|
leaving_move_line = leaving_move_by_lot.get(lot_id, False)
|
|
leaving = bool(leaving_move_line and (
|
|
leaving_move_line.move_id.date_deadline <= line.return_date
|
|
and not (
|
|
# will return in time from an other renting
|
|
leaving_move_line.move_id.sale_line_id.return_date
|
|
and leaving_move_line.move_id.sale_line_id.return_date <= line.reservation_begin
|
|
))
|
|
)
|
|
in_stock = not leaving
|
|
else:
|
|
incoming_move_line = incoming_move_by_lot.get(lot_id, False)
|
|
incoming = bool(incoming_move_line and incoming_move_line.move_id.date_deadline <= line.reservation_begin)
|
|
in_stock = incoming
|
|
if not in_stock:
|
|
line.available_reserved_lots = False
|
|
break
|
|
|
|
def _partition_so_lines_by_rental_period(self):
|
|
""" Return a partition of sale.order.line based on (from_date, to_date, warehouse_id)
|
|
"""
|
|
now = fields.Datetime.now()
|
|
lines_grouping_key = {
|
|
line.id: (line.reservation_begin, line.return_date, line.order_id.warehouse_id.id)
|
|
for line in self
|
|
}
|
|
keyfunc = lambda line_id: (max(lines_grouping_key[line_id][0], now), lines_grouping_key[line_id][1], lines_grouping_key[line_id][2])
|
|
return tools_groupby(self._ids, key=keyfunc)
|
|
|
|
@api.depends('reservation_begin', 'return_date', 'product_id')
|
|
def _compute_qty_at_date(self):
|
|
non_rental = self.filtered(lambda sol: not sol.is_rental)
|
|
super(SaleOrderLine, non_rental)._compute_qty_at_date()
|
|
rented_product_lines = (self - non_rental).filtered(
|
|
lambda l: l.product_id and l.product_id.is_storable
|
|
)
|
|
line_default_values = {
|
|
'virtual_available_at_date': 0.0,
|
|
'scheduled_date': False,
|
|
'forecast_expected_date': False,
|
|
'free_qty_today': 0.0,
|
|
'qty_available_today': False,
|
|
}
|
|
for (from_date, to_date, warehouse_id), line_ids in rented_product_lines._partition_so_lines_by_rental_period():
|
|
lines = self.env['sale.order.line'].browse(line_ids)
|
|
for line in lines:
|
|
if from_date <= fields.Datetime.now():
|
|
rentable_qty = line.product_id.with_context(
|
|
from_date=from_date,
|
|
to_date=to_date,
|
|
warehouse_id=warehouse_id).qty_available
|
|
else:
|
|
# For performance reason it is not reasonable to find the minimal forecasted quantity
|
|
# during the rental period so that we fallback on the forecasted quantity at the
|
|
# first day of the rental period.
|
|
rentable_qty = line.product_id.with_context(
|
|
from_date=False,
|
|
to_date=from_date,
|
|
warehouse_id=warehouse_id).virtual_available
|
|
# The rented_qty_during_period can overlap with planned
|
|
# incoming/outgoing moves taken into account by the virtual_available qty
|
|
rentable_qty += line.product_id._get_virtual_unavailable_qty_in_rent(
|
|
pivot_date=from_date,
|
|
ignored_soline_id=line and line.state == 'draft' and line.id,
|
|
warehouse_id=line.order_id.warehouse_id.id,
|
|
)
|
|
rented_qty_during_period = line.product_id._get_unavailable_qty(
|
|
from_date, to_date,
|
|
ignored_soline_id=line and line.id,
|
|
warehouse_id=line.order_id.warehouse_id.id,
|
|
)
|
|
virtual_available_at_date = max(rentable_qty - rented_qty_during_period, 0)
|
|
line.update(dict(line_default_values,
|
|
virtual_available_at_date=virtual_available_at_date,
|
|
scheduled_date=from_date,
|
|
free_qty_today=virtual_available_at_date)
|
|
)
|
|
((self - non_rental) - rented_product_lines).update(line_default_values)
|
|
|
|
@api.depends('is_rental')
|
|
def _compute_qty_delivered_method(self):
|
|
"""Allow modification of delivered qty without depending on stock moves."""
|
|
rental_lines = self.filtered('is_rental')
|
|
super(SaleOrderLine, self - rental_lines)._compute_qty_delivered_method()
|
|
rental_lines.qty_delivered_method = 'manual'
|
|
|
|
def _read_group_expand_reserved_lot_ids(self, lots, domain):
|
|
domain = Domain.AND([domain, Domain('product_id.tracking', '!=', 'none')])
|
|
products = self.env['sale.order.line'].search_fetch(domain, ['product_id']).product_id
|
|
return lots + self.env['stock.lot']._get_available_lots(products)
|
|
|
|
def write(self, vals):
|
|
"""Move product quantities on pickup/return in case of rental orders.
|
|
|
|
When qty_delivered or qty_returned are changed (and/or pickedup_lot_ids/returned_lot_ids),
|
|
we need to move those quants to make sure they aren't seen as available in the stock.
|
|
For quantities, the quantity is requested in the warehouse (self.order_id.warehouse_id) through stock move generation.
|
|
For serial numbers(lots), lots are found one by one and then a stock move is generated based on the quant location itself.
|
|
|
|
The destination location is the independent internal location of the company dedicated to stock in rental, to still count
|
|
in inventory valuation and company assets.
|
|
|
|
When quantity/lots are decreased/removed, we decrease the quantity in the stock moves made by previous corresponding write call.
|
|
"""
|
|
if (
|
|
not any(key in vals for key in ['qty_delivered', 'pickedup_lot_ids', 'qty_returned', 'returned_lot_ids'])
|
|
or (self._are_rental_pickings_enabled() and not self.env.context.get('rental_direct_stock_move'))
|
|
):
|
|
# If nothing to catch for rental: usual write behavior
|
|
return super().write(vals)
|
|
|
|
# TODO add context for disabling stock moves in write ?
|
|
old_vals = dict()
|
|
movable_confirmed_rental_lines = self.filtered(
|
|
lambda sol: sol.is_rental
|
|
and sol.state == 'sale'
|
|
and sol.product_id.type == "consu")
|
|
for sol in movable_confirmed_rental_lines:
|
|
old_vals[sol.id] = (sol.pickedup_lot_ids, sol.returned_lot_ids) if sol.product_id.tracking == 'serial' else (sol.qty_delivered, sol.qty_returned)
|
|
if vals.get('pickedup_lot_ids', False) and vals['pickedup_lot_ids'][0][0] == 6:
|
|
pickedup_lot_ids = vals['pickedup_lot_ids'][0][2]
|
|
if sol.product_uom_qty == len(pickedup_lot_ids) and pickedup_lot_ids != sol.reserved_lot_ids.ids:
|
|
""" When setting the pickedup_lots:
|
|
If the total reserved quantity is picked_up we need to unreserve
|
|
the reserved_lots not picked to ensure the consistency of rental reservations.
|
|
NOTE: This is only guaranteed for generic 6, _, _ orm magic commands.
|
|
"""
|
|
vals['reserved_lot_ids'] = vals['pickedup_lot_ids']
|
|
|
|
res = super().write(vals)
|
|
|
|
self._write_rental_lines(movable_confirmed_rental_lines, old_vals, vals)
|
|
# TODO constraint s.t. qty_returned cannot be > than qty_delivered (and same for lots)
|
|
return res
|
|
|
|
def _write_rental_lines(self, lines, old_vals, vals):
|
|
if not lines:
|
|
return
|
|
|
|
lines.mapped('company_id').filtered(lambda company: not company.rental_loc_id)._create_rental_location()
|
|
# to undo stock moves partially: what if location has changed? :x
|
|
# can we ascertain the warehouse_id.lot_stock_id of a sale.order doesn't change???
|
|
|
|
for sol in lines:
|
|
sol = sol.with_company(sol.company_id)
|
|
rented_location = sol.company_id.rental_loc_id
|
|
stock_location = sol.order_id.warehouse_id.lot_stock_id
|
|
if sol.product_id.tracking == 'serial' and (vals.get('pickedup_lot_ids', False) or vals.get('returned_lot_ids', False)):
|
|
# for product tracked by serial numbers: move the lots
|
|
if vals.get('pickedup_lot_ids', False):
|
|
pickedup_lots = sol.pickedup_lot_ids - old_vals[sol.id][0]
|
|
removed_pickedup_lots = old_vals[sol.id][0] - sol.pickedup_lot_ids
|
|
sol._move_serials(pickedup_lots, stock_location, rented_location)
|
|
sol._return_serials(removed_pickedup_lots, rented_location, stock_location)
|
|
if vals.get('returned_lot_ids', False):
|
|
returned_lots = sol.returned_lot_ids - old_vals[sol.id][1]
|
|
removed_returned_lots = old_vals[sol.id][1] - sol.returned_lot_ids
|
|
sol._move_serials(returned_lots, rented_location, stock_location)
|
|
sol._return_serials(removed_returned_lots, stock_location, rented_location)
|
|
elif sol.product_id.tracking != 'serial' and any(k in vals for k in ('qty_delivered', 'qty_returned')):
|
|
# for products not tracked: move quantities
|
|
qty_delivered_change = sol.qty_delivered - old_vals[sol.id][0]
|
|
qty_returned_change = sol.qty_returned - old_vals[sol.id][1]
|
|
if qty_delivered_change > 0:
|
|
sol._move_qty(qty_delivered_change, stock_location, rented_location)
|
|
elif qty_delivered_change < 0:
|
|
sol._return_qty(-qty_delivered_change, stock_location, rented_location)
|
|
|
|
if qty_returned_change > 0.0:
|
|
sol._move_qty(qty_returned_change, rented_location, stock_location)
|
|
elif qty_returned_change < 0.0:
|
|
sol._return_qty(-qty_returned_change, rented_location, stock_location)
|
|
|
|
def _move_serials(self, lot_ids, location_id, location_dest_id):
|
|
"""Move the given lots from location_id to location_dest_id.
|
|
|
|
:param stock.lot lot_ids:
|
|
:param stock.location location_id:
|
|
:param stock.location location_dest_id:
|
|
"""
|
|
if not lot_ids:
|
|
return
|
|
rental_stock_move = self.env['stock.move'].create({
|
|
'product_id': self.product_id.id,
|
|
'product_uom_qty': len(lot_ids),
|
|
'product_uom': self.product_id.uom_id.id,
|
|
'location_id': location_id.id,
|
|
'location_dest_id': location_dest_id.id,
|
|
'partner_id': self.order_partner_id.id,
|
|
'sale_line_id': self.id,
|
|
})
|
|
|
|
for lot_id in lot_ids:
|
|
lot_quant = self.env['stock.quant']._gather(self.product_id, location_id, lot_id)
|
|
lot_quant = lot_quant.filtered(lambda quant: quant.quantity == 1.0)
|
|
if not lot_quant:
|
|
raise ValidationError(_("No valid quant has been found in location %(location)s for serial number %(serial_number)s!", location=location_id.name, serial_number=lot_id.name))
|
|
# Best fallback strategy??
|
|
# Make a stock move without specifying quants and lots?
|
|
# Let the move be created with the erroneous quant???
|
|
# As we are using serial numbers, only one quant is expected
|
|
ml = self.env['stock.move.line'].create(rental_stock_move._prepare_move_line_vals(reserved_quant=lot_quant))
|
|
ml['quantity'] = 1
|
|
|
|
rental_stock_move.picked = True
|
|
rental_stock_move._action_done()
|
|
|
|
def _return_serials(self, lot_ids, location_id, location_dest_id):
|
|
"""Undo the move of lot_ids from location_id to location_dest_id.
|
|
|
|
:param stock.lot lot_ids:
|
|
:param stock.location location_id:
|
|
:param stock.location location_dest_id:
|
|
"""
|
|
# VFE NOTE: or use stock moves to undo return/pickups???
|
|
if not lot_ids:
|
|
return
|
|
rental_stock_move = self.env['stock.move'].search([
|
|
('sale_line_id', '=', self.id),
|
|
('location_id', '=', location_id.id),
|
|
('location_dest_id', '=', location_dest_id.id)
|
|
])
|
|
for ml in rental_stock_move.mapped('move_line_ids'):
|
|
# update move lines qties.
|
|
if ml.lot_id.id in lot_ids:
|
|
ml.quantity = 0.0
|
|
|
|
rental_stock_move.product_uom_qty -= len(lot_ids)
|
|
|
|
def _move_qty(self, qty, location_id, location_dest_id):
|
|
"""Move qty from location_id to location_dest_id.
|
|
|
|
:param float qty:
|
|
:param stock.location location_id:
|
|
:param stock.location location_dest_id:
|
|
"""
|
|
rental_stock_move = self.env['stock.move'].create({
|
|
'product_id': self.product_id.id,
|
|
'product_uom_qty': qty,
|
|
'product_uom': self.product_id.uom_id.id,
|
|
'location_id': location_id.id,
|
|
'location_dest_id': location_dest_id.id,
|
|
'partner_id': self.order_partner_id.id,
|
|
'sale_line_id': self.id,
|
|
'state': 'confirmed',
|
|
})
|
|
rental_stock_moves = rental_stock_move._set_rental_sm_qty()
|
|
for move in rental_stock_moves:
|
|
move._action_assign()
|
|
move.quantity = move.product_uom_qty
|
|
move.picked = True
|
|
rental_stock_moves._action_done()
|
|
|
|
def _return_qty(self, qty, location_id, location_dest_id):
|
|
"""Undo a qty move (partially or totally depending on qty).
|
|
|
|
:param float qty:
|
|
:param stock.location location_id:
|
|
:param stock.location location_dest_id:
|
|
"""
|
|
# VFE NOTE: or use stock moves to undo return/pickups???
|
|
rental_stock_move = self.env['stock.move'].search([
|
|
('sale_line_id', '=', self.id),
|
|
('location_id', '=', location_id.id),
|
|
('location_dest_id', '=', location_dest_id.id)
|
|
], order='date desc')
|
|
|
|
for ml in rental_stock_move.mapped('move_line_ids'):
|
|
# update move lines qties.
|
|
qty -= ml.quantity
|
|
ml.quantity = 0.0 if qty > 0.0 else -qty
|
|
|
|
if qty <= 0.0:
|
|
return True
|
|
# TODO ? ml.move_id.product_uom_qty -= decrease of qty
|
|
|
|
return qty <= 0.0
|
|
|
|
def web_gantt_write(self, vals):
|
|
"""Override of `sale_renting` to handle stock-specific constraints during schedule updates.
|
|
|
|
This method checks for stock/lot overbooking issues and sends an action if not enough
|
|
lots are reserved for the requested product quantity.
|
|
|
|
:param dict vals: Values to update on the sale order line.
|
|
:return: A dictionary containing notifications and/or actions, if applicable. Format: {
|
|
'notifications': list[{'type': str, 'message': str, 'code': str}],
|
|
'actions': list[dict] # Action dictionaries
|
|
}
|
|
:rtype: dict
|
|
"""
|
|
result = super().web_gantt_write(vals)
|
|
|
|
order_lines = self.order_id.order_line
|
|
storable_products = order_lines.product_id.filtered('is_storable')
|
|
if not storable_products:
|
|
return result
|
|
|
|
# Check for stock quantity consistency, and warn in case there is not enough stock.
|
|
stock_quants = dict(self.env['stock.quant']._read_group(
|
|
Domain([
|
|
('product_id', 'in', storable_products.ids),
|
|
('location_id.usage', '=', 'internal'),
|
|
]),
|
|
groupby=['product_id'],
|
|
aggregates=['quantity:sum'],
|
|
))
|
|
reserved_quants = dict(self.env['sale.order.line']._read_group(
|
|
Domain([
|
|
('product_id', 'in', storable_products.ids),
|
|
('is_rental', '=', True),
|
|
('state', '=', 'sale'),
|
|
# Filter the SOLs that intersect with the current SOL
|
|
('start_date', '<=', self.return_date),
|
|
('return_date', '>=', self.start_date),
|
|
]),
|
|
groupby=['product_id'],
|
|
aggregates=['product_uom_qty:sum'],
|
|
))
|
|
for product in stock_quants.keys() | reserved_quants.keys():
|
|
if stock_quants.get(product, 0) < reserved_quants.get(product, 0):
|
|
result['notifications'].append({
|
|
'type': 'warning',
|
|
'message': self.env._(
|
|
"Not enough %(product)s in stock.\n"
|
|
"%(reserved_qty)s reserved but only %(stock_qty)s in stock.",
|
|
product=product.display_name,
|
|
reserved_qty=reserved_quants.get(product, 0),
|
|
stock_qty=stock_quants.get(product, 0),
|
|
),
|
|
'code': 'stock_inconsistency',
|
|
})
|
|
|
|
if not self._are_rental_pickings_enabled():
|
|
return result
|
|
|
|
# Check for lot consistency, and warn if lots are reserved on multiple SOLs at the same time
|
|
order_reserved_lots = order_lines.reserved_lot_ids
|
|
lots_reserved_in_different_order = self.env['sale.order.line'].search_fetch(
|
|
Domain([
|
|
('reserved_lot_ids', 'in', order_reserved_lots.ids),
|
|
('id', 'not in', order_lines.ids),
|
|
('is_rental', '=', True),
|
|
# Filter the SOLs that intersect with the current SOL
|
|
('start_date', '<=', self.return_date),
|
|
('return_date', '>=', self.start_date),
|
|
]),
|
|
['reserved_lot_ids'],
|
|
).reserved_lot_ids & order_reserved_lots
|
|
if lots_reserved_in_different_order:
|
|
result['notifications'].append({
|
|
'type': 'warning',
|
|
'message': self.env._(
|
|
"%(lots)s %(are)s reserved on multiple order lines",
|
|
lots=", ".join(lots_reserved_in_different_order.mapped('display_name')),
|
|
are='are' if len(lots_reserved_in_different_order) > 1 else 'is',
|
|
),
|
|
'code': 'lot_overbooking',
|
|
})
|
|
|
|
# Add action to allocate more lots if their is not enough reserved for the quantity asked
|
|
if vals.get('reserved_lot_ids') and self.product_uom_qty > len(self.reserved_lot_ids):
|
|
view = self.env.ref('sale_stock_renting.sale_order_line_form_lot_allocation')
|
|
result['notifications'].append({
|
|
'type': 'warning',
|
|
'message': self.env._(
|
|
"The number of reserved lots is less than the quantity required for this order"
|
|
" line. Please allocate additional lots."
|
|
),
|
|
'code': 'insufficient_reserved_lots',
|
|
})
|
|
result['actions'].append({
|
|
'name': self.env._("Lot Allocation"),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'sale.order.line',
|
|
'views': [(view.id, 'form')],
|
|
'res_id': self.id,
|
|
'target': 'new',
|
|
})
|
|
|
|
return result
|
|
|
|
@api.constrains('product_id')
|
|
def _stock_consistency(self):
|
|
for line in self.filtered('is_rental'):
|
|
moves = line.move_ids.filtered(lambda m: m.state != 'cancel')
|
|
if moves and moves.mapped('product_id') != line.product_id:
|
|
raise ValidationError(_("You cannot change the product of lines linked to stock moves."))
|
|
|
|
def _create_procurements(self, product_qty, procurement_uom, values):
|
|
""" Change the destination for rental procurement. """
|
|
if self.is_rental and self._are_rental_pickings_enabled():
|
|
values['route_ids'] = values.get('route_ids', self.env['stock.route']) | (self.env.ref('sale_stock_renting.route_rental', raise_if_not_found=False) or self.env['stock.route'])
|
|
delivery_values = {
|
|
**values,
|
|
'date_planned': self.order_id.rental_start_date,
|
|
'date_deadline': self.order_id.rental_start_date,
|
|
}
|
|
return_values = {
|
|
**values,
|
|
'date_planned': self.order_id.rental_return_date,
|
|
'date_deadline': self.order_id.rental_return_date,
|
|
}
|
|
return [
|
|
self.env['stock.rule'].Procurement(
|
|
self.product_id, product_qty, procurement_uom, self.order_id.company_id.rental_loc_id,
|
|
self.product_id.display_name, self.order_id.name, self.order_id.company_id, delivery_values),
|
|
self.env['stock.rule'].Procurement(
|
|
self.product_id, product_qty, procurement_uom, self.order_id.warehouse_id.lot_stock_id,
|
|
self.product_id.display_name, self.order_id.name, self.order_id.company_id, return_values)]
|
|
return super()._create_procurements(product_qty, procurement_uom, values)
|
|
|
|
def _action_launch_stock_rule(self, **kwargs):
|
|
""" If the rental picking setting is deactivated:
|
|
Disable stock moves for rental order lines.
|
|
Stock moves for rental orders are created on pickup/return.
|
|
The rental reservations are not propagated in the stock
|
|
until the effective pickup or returns.
|
|
|
|
If the rental picking setting is activated:
|
|
Process all lines at the same time. """
|
|
if not self or self._are_rental_pickings_enabled():
|
|
super()._action_launch_stock_rule(**kwargs)
|
|
returns = self.move_ids.filtered(lambda m: m.location_id == self.company_id.rental_loc_id)
|
|
picks = self.move_ids.filtered(lambda m: m.location_id == self.warehouse_id.lot_stock_id)
|
|
moves_by_order_line = defaultdict(lambda: {'picks': self.env['stock.move'], 'returns': self.env['stock.move']})
|
|
for move in picks:
|
|
if move.state in ('done', 'cancel'):
|
|
continue
|
|
moves_by_order_line[move.sale_line_id]['picks'] |= move
|
|
for move in returns:
|
|
if move.state in ('done', 'cancel'):
|
|
continue
|
|
moves_by_order_line[move.sale_line_id]['returns'] |= move
|
|
returns._do_unreserve()
|
|
for moves in moves_by_order_line.values():
|
|
moves['returns'].write({
|
|
'move_orig_ids': [Command.link(pick.id) for pick in moves['picks']],
|
|
'procure_method': 'make_to_order'
|
|
})
|
|
moves['returns'].picking_id.return_id = moves['picks'].picking_id[:1]
|
|
returns._recompute_state()
|
|
else:
|
|
other_lines = self.filtered(lambda sol: not sol.is_rental)
|
|
super(SaleOrderLine, other_lines)._action_launch_stock_rule(**kwargs)
|
|
|
|
def _get_outgoing_incoming_moves(self, strict=True):
|
|
outgoing_moves, incoming_moves = super()._get_outgoing_incoming_moves(strict)
|
|
if self.is_rental and self._are_rental_pickings_enabled():
|
|
for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and r.location_dest_usage != 'inventory' and self.product_id == r.product_id):
|
|
if (
|
|
strict and move.location_dest_id == self.company_id.rental_loc_id or
|
|
not strict and move.location_id._child_of(self.order_id.warehouse_id.lot_stock_id)
|
|
):
|
|
outgoing_moves |= move
|
|
elif strict and move.location_id == self.company_id.rental_loc_id:
|
|
incoming_moves |= move
|
|
|
|
return outgoing_moves, incoming_moves
|
|
|
|
def _prepare_qty_delivered(self):
|
|
delivered_qties = super()._prepare_qty_delivered()
|
|
|
|
if not self._are_rental_pickings_enabled():
|
|
return delivered_qties
|
|
|
|
for line in self:
|
|
if line.is_rental and line.product_id.type == 'consu':
|
|
qty = 0.0
|
|
outgoing_moves, dummy = line._get_outgoing_incoming_moves()
|
|
for move in outgoing_moves:
|
|
if move.state != 'done':
|
|
continue
|
|
qty += move.product_uom._compute_quantity(move.quantity, line.product_uom_id, rounding_method='HALF-UP')
|
|
delivered_qties[line] = qty
|
|
return delivered_qties
|
|
|
|
@api.depends('pickedup_lot_ids', 'returned_lot_ids', 'reserved_lot_ids')
|
|
def _compute_unavailable_lots(self):
|
|
"""Unavailable lots = reserved_lots U pickedup_lots - returned_lots."""
|
|
for line in self:
|
|
line.unavailable_lot_ids = (line.reserved_lot_ids | line.pickedup_lot_ids) - line.returned_lot_ids
|
|
|
|
@api.depends('start_date', 'is_rental')
|
|
def _compute_reservation_begin(self):
|
|
lines = self.filtered(lambda line: line.is_rental)
|
|
for line in lines:
|
|
padding_timedelta_before = timedelta(hours=line.product_id.preparation_time)
|
|
line.reservation_begin = line.start_date and line.start_date - padding_timedelta_before
|
|
(self - lines).reservation_begin = None
|
|
|
|
def _get_rented_quantities(self, mandatory_dates):
|
|
""" Return the quantities that are picked up (positive value) or returned (negative value),
|
|
keyed by their pickup/return dates.
|
|
|
|
This method also returns a sorted list of dates of interest, which is the union of the
|
|
pickup/return dates and mandatory_dates.
|
|
|
|
:param list(datetime) mandatory_dates: typically a "from" and a "to" date defining an
|
|
interval of interest to the caller.
|
|
"""
|
|
if not self:
|
|
return defaultdict(float), sorted(set(mandatory_dates))
|
|
self.product_id.ensure_one()
|
|
rented_quantities = defaultdict(float)
|
|
now = fields.Datetime.now()
|
|
for so_line in self.filtered('is_rental'):
|
|
rented_quantities[so_line.reservation_begin] += so_line.product_uom_qty
|
|
rented_quantities[so_line.return_date] -= so_line.product_uom_qty
|
|
# Adjust the rented quantities for early pickups.
|
|
# We don't know when the order was picked up, so we apply the adjustment from the
|
|
# current date to the expected pickup date.
|
|
if so_line.reservation_begin > now and so_line.qty_delivered > 0:
|
|
rented_quantities[now] += so_line.qty_delivered
|
|
rented_quantities[so_line.reservation_begin] -= so_line.qty_delivered
|
|
# Adjust the rented quantities for early returns.
|
|
# We don't know when the order was returned, so we apply the adjustment from the current
|
|
# date to the expected return date.
|
|
if so_line.return_date > now and so_line.qty_returned > 0:
|
|
rented_quantities[now] -= so_line.qty_returned
|
|
rented_quantities[so_line.return_date] += so_line.qty_returned
|
|
|
|
key_dates = sorted(set(rented_quantities.keys()) | set(mandatory_dates))
|
|
return rented_quantities, key_dates
|
|
|
|
@api.model
|
|
def _are_rental_pickings_enabled(self):
|
|
return self.env['res.groups']._is_feature_enabled('sale_stock_renting.group_rental_stock_picking')
|
|
|
|
def _prepare_procurement_values(self):
|
|
values = super()._prepare_procurement_values()
|
|
if self._are_rental_pickings_enabled() and self.is_rental:
|
|
values['to_refund'] = False
|
|
return values
|