forked from Mapan/odoo17e
423 lines
21 KiB
Python
423 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from datetime import timedelta
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.osv import expression
|
|
from odoo.tools.misc import groupby as tools_groupby
|
|
|
|
|
|
class RentalOrderLine(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', domain="[('product_id','=',product_id)]", copy=False)
|
|
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)
|
|
available_reserved_lots = fields.Boolean(compute='_compute_available_reserved_lots')
|
|
|
|
@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.env.user.has_group('sale_stock_renting.group_rental_stock_picking'):
|
|
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_id = self.env.ref('stock.stock_location_locations_partner')
|
|
|
|
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(
|
|
expression.AND([domain, [
|
|
('location_usage', '=', 'internal'),
|
|
('location_dest_id', 'child_of', partner_location_id.id),
|
|
]]),
|
|
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(
|
|
expression.AND([domain, [
|
|
('location_id', 'child_of', partner_location_id.id),
|
|
('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_id.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(RentalOrderLine, non_rental)._compute_qty_at_date()
|
|
rented_product_lines = (self - non_rental).filtered(
|
|
lambda l: l.product_id and l.product_id.type == "product"
|
|
)
|
|
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:
|
|
rentable_qty = line.product_id.with_context(
|
|
from_date=from_date,
|
|
to_date=to_date,
|
|
warehouse=warehouse_id).qty_available
|
|
if from_date > fields.Datetime.now():
|
|
rentable_qty += line.product_id.with_context(warehouse_id=line.order_id.warehouse_id.id).qty_in_rent
|
|
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(RentalOrderLine, self - rental_lines)._compute_qty_delivered_method()
|
|
rental_lines.qty_delivered_method = 'manual'
|
|
|
|
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.env.user.has_group('sale_stock_renting.group_rental_stock_picking'):
|
|
# If nothing to catch for rental: usual write behavior
|
|
return super(RentalOrderLine, self).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 in ["product", "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(RentalOrderLine, self).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,
|
|
'name': _("Rental move:") + " %s" % (self.order_id.name),
|
|
})
|
|
|
|
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 %s for serial number %s!", location_id.name, 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,
|
|
'name': _("Rental move:") + " %s" % (self.order_id.name),
|
|
'state': 'confirmed',
|
|
})
|
|
rental_stock_move._action_assign()
|
|
rental_stock_move.quantity = qty
|
|
rental_stock_move.picked = True
|
|
rental_stock_move._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
|
|
|
|
@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 _prepare_procurement_values(self, group_id=False):
|
|
""" Change the planned and deadline dates of rental delivery pickings. """
|
|
values = super()._prepare_procurement_values(group_id)
|
|
if self.is_rental and self._are_rental_pickings_enabled():
|
|
values.update({
|
|
'date_planned': self.order_id.rental_start_date,
|
|
'date_deadline': self.order_id.rental_start_date,
|
|
})
|
|
return values
|
|
|
|
def _get_qty_procurement(self, previous_product_uom_qty=False):
|
|
qty = super()._get_qty_procurement(previous_product_uom_qty)
|
|
if self.is_rental and self._are_rental_pickings_enabled():
|
|
outgoing_moves = self.move_ids.filtered(lambda m: m.location_dest_id == m.company_id.rental_loc_id and m.state != 'cancel' and not m.scrapped and self.product_id == m.product_id)
|
|
for move in outgoing_moves:
|
|
qty += move.product_uom._compute_quantity(move.product_qty, self.product_uom, rounding_method='HALF-UP')
|
|
return qty
|
|
|
|
def _create_procurement(self, product_qty, procurement_uom, values):
|
|
""" Change the destination for rental procurement groups. """
|
|
if self.is_rental:
|
|
return self.env['procurement.group'].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, values)
|
|
return super()._create_procurement(product_qty, procurement_uom, values)
|
|
|
|
def _action_launch_stock_rule(self, previous_product_uom_qty=False):
|
|
""" 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(previous_product_uom_qty)
|
|
else:
|
|
other_lines = self.filtered(lambda sol: not sol.is_rental)
|
|
super(RentalOrderLine, other_lines)._action_launch_stock_rule(previous_product_uom_qty)
|
|
|
|
def _get_outgoing_incoming_moves(self):
|
|
outgoing_moves, incoming_moves = super()._get_outgoing_incoming_moves()
|
|
if self.is_rental and self._are_rental_pickings_enabled():
|
|
for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id):
|
|
if move.location_dest_id == self.company_id.rental_loc_id:
|
|
outgoing_moves |= move
|
|
elif move.location_id == self.company_id.rental_loc_id:
|
|
incoming_moves |= move
|
|
|
|
return outgoing_moves, incoming_moves
|
|
|
|
def _compute_qty_delivered(self):
|
|
super()._compute_qty_delivered()
|
|
|
|
if not self._are_rental_pickings_enabled():
|
|
return
|
|
|
|
for line in self:
|
|
if line.is_rental:
|
|
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, rounding_method='HALF-UP')
|
|
line.qty_delivered = qty
|
|
|
|
@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 _are_rental_pickings_enabled(self):
|
|
if self:
|
|
return self[0].order_id.create_uid.has_group(
|
|
'sale_stock_renting.group_rental_stock_picking'
|
|
)
|
|
return self.env.user.has_group('sale_stock_renting.group_rental_stock_picking')
|