stock/report/report_stock_reception.py

381 lines
20 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict, OrderedDict
from odoo import _, api, models
from odoo.tools import float_compare, float_is_zero, format_date
class ReceptionReport(models.AbstractModel):
_name = 'report.stock.report_reception'
_description = "Stock Reception Report"
@api.model
def get_report_data(self, docids, data):
report_values = self._get_report_values(docids, data)
report_values['docs'] = self._format_html_docs(report_values.get('docs', False))
report_values['sources_info'] = self._format_html_sources_info(report_values.get('sources_to_lines', {}))
report_values['sources_to_lines'] = self._format_html_sources_to_lines(report_values.get('sources_to_lines', {}))
report_values['sources_to_formatted_scheduled_date'] = self._format_html_sources_to_date(report_values.get('sources_to_formatted_scheduled_date', {}))
report_values['show_uom'] = self.env.user.has_group('uom.group_uom')
return report_values
@api.model
def _get_report_values(self, docids, data=None):
''' This report is flexibly designed to work with both individual and batch pickings.
'''
docs = self._get_docs(docids)
doc_states = docs.mapped('state')
# unsupported cases
doc_types = self._get_doc_types()
if not docs:
msg = _("No %s selected or a delivery order selected", doc_types)
elif 'done' in doc_states and len(set(doc_states)) > 1:
docs = False
msg = _("This report cannot be used for done and not done %s at the same time", doc_types)
if not docs:
return {'pickings': False, 'reason': msg}
# incoming move qtys
product_to_qty_draft = defaultdict(float)
product_to_qty_to_assign = defaultdict(list)
product_to_total_assigned = defaultdict(lambda: [0.0, []])
# to support batch pickings we need to track the total already assigned
move_ids = self._get_moves(docs)
assigned_moves = move_ids.mapped('move_dest_ids')
product_to_assigned_qty = defaultdict(float)
for assigned in assigned_moves:
product_to_assigned_qty[assigned.product_id] += assigned.product_qty
for move in move_ids:
qty_already_assigned = 0
if move.move_dest_ids:
qty_already_assigned = min(product_to_assigned_qty[move.product_id], move.product_qty)
product_to_assigned_qty[move.product_id] -= qty_already_assigned
if qty_already_assigned:
product_to_total_assigned[move.product_id][0] += qty_already_assigned
product_to_total_assigned[move.product_id][1].append(move.id)
if move.product_qty != qty_already_assigned:
if move.state == 'draft':
product_to_qty_draft[move.product_id] += move.product_qty - qty_already_assigned
else:
quantity_to_assign = move.product_qty
if not move.product_qty:
# if immediate transfer is not Done and quantity_done hasn't been edited, then move.product_qty will incorrectly = 1 (due to default)
quantity_to_assign = move.product_uom._compute_quantity(move.quantity, move.product_id.uom_id, rounding_method='HALF-UP')
product_to_qty_to_assign[move.product_id].append((quantity_to_assign - qty_already_assigned, move))
# only match for non-mto moves in same warehouse
warehouse = docs[0].picking_type_id.warehouse_id
wh_location_ids = self.env['stock.location']._search([('id', 'child_of', warehouse.view_location_id.id), ('usage', '!=', 'supplier')])
allowed_states = ['confirmed', 'partially_available', 'waiting']
if 'done' in doc_states:
# only done moves are allowed to be assigned to already reserved moves
allowed_states += ['assigned']
outs = self.env['stock.move'].search(
[
('state', 'in', allowed_states),
('product_qty', '>', 0),
('location_id', 'in', wh_location_ids),
('move_orig_ids', '=', False),
('product_id', 'in',
[p.id for p in list(product_to_qty_to_assign.keys()) + list(product_to_qty_draft.keys())]),
] + self._get_extra_domain(docs),
order='reservation_date, priority desc, date, id')
products_to_outs = defaultdict(list)
for out in outs:
products_to_outs[out.product_id].append(out)
sources_to_lines = defaultdict(list) # group by source so we can print them together
# show potential moves that can be assigned
for product_id, outs in products_to_outs.items():
for out in outs:
# we expect len(source) = 2 when picking + origin [e.g. SO] and len() = 1 otherwise [e.g. MO]
source = (out._get_source_document(),)
if not source:
continue
if out.picking_id and source[0] != out.picking_id:
source = (out.picking_id, source[0])
qty_to_reserve = out.product_qty
product_uom = out.product_id.uom_id
if 'done' not in doc_states and out.state == 'partially_available':
qty_to_reserve -= out.product_uom._compute_quantity(out.quantity, product_uom)
moves_in_ids = []
quantity = 0
for move_in_qty, move_in in product_to_qty_to_assign[out.product_id]:
moves_in_ids.append(move_in.id)
if float_compare(quantity + move_in_qty, qty_to_reserve, precision_rounding=product_uom.rounding) <= 0:
qty_to_add = move_in_qty
move_in_qty = 0
else:
qty_to_add = qty_to_reserve - quantity
move_in_qty -= qty_to_add
quantity += qty_to_add
if move_in_qty:
product_to_qty_to_assign[out.product_id][0] = (move_in_qty, move_in)
else:
product_to_qty_to_assign[out.product_id] = product_to_qty_to_assign[out.product_id][1:]
if float_compare(qty_to_reserve, quantity, precision_rounding=product_uom.rounding) == 0:
break
if not float_is_zero(quantity, precision_rounding=product_uom.rounding):
sources_to_lines[source].append(self._prepare_report_line(quantity, product_id, out, source[0], move_ins=self.env['stock.move'].browse(moves_in_ids)))
# draft qtys can be shown but not assigned
qty_expected = product_to_qty_draft.get(product_id, 0)
if float_compare(qty_to_reserve, quantity, precision_rounding=product_uom.rounding) > 0 and\
not float_is_zero(qty_expected, precision_rounding=product_uom.rounding):
to_expect = min(qty_expected, qty_to_reserve - quantity)
sources_to_lines[source].append(self._prepare_report_line(to_expect, product_id, out, source[0], is_qty_assignable=False))
product_to_qty_draft[product_id] -= to_expect
# show already assigned moves
for product_id, qty_and_ins in product_to_total_assigned.items():
total_assigned = qty_and_ins[0]
moves_in = self.env['stock.move'].browse(qty_and_ins[1])
out_moves = moves_in.move_dest_ids
for out_move in out_moves:
if float_is_zero(total_assigned, precision_rounding=out_move.product_id.uom_id.rounding):
# it is possible there are different in moves linked to the same out moves due to batch
# => we guess as to which outs correspond to this report...
continue
source = (out_move._get_source_document(),)
if not source:
continue
if out_move.picking_id and source[0] != out_move.picking_id:
source = (out_move.picking_id, source[0])
qty_assigned = min(total_assigned, out_move.product_qty)
sources_to_lines[source].append(
self._prepare_report_line(qty_assigned, product_id, out_move, source[0], is_assigned=True, move_ins=moves_in))
# dates aren't auto-formatted when printed in report :(
sources_to_formatted_scheduled_date = defaultdict(list)
for source, dummy in sources_to_lines.items():
sources_to_formatted_scheduled_date[source] = self._get_formatted_scheduled_date(source[0])
return {
'data': data,
'doc_ids': docids,
'doc_model': self._get_doc_model(),
'sources_to_lines': sources_to_lines,
'precision': self.env['decimal.precision'].precision_get('Product Unit of Measure'),
'docs': docs,
'sources_to_formatted_scheduled_date': sources_to_formatted_scheduled_date,
}
def _prepare_report_line(self, quantity, product, move_out, source=False, is_assigned=False, is_qty_assignable=True, move_ins=False):
return {
'source': source,
'product': {
'id': product.id,
'display_name': product.display_name
},
'uom': product.uom_id.display_name,
'quantity': quantity,
'is_qty_assignable': is_qty_assignable,
'move_out': move_out,
'is_assigned': is_assigned,
'move_ins': move_ins and move_ins.ids or False,
}
def _get_docs(self, docids):
docids = self.env.context.get('default_picking_ids', docids)
return self.env['stock.picking'].search([('id', 'in', docids), ('picking_type_code', '!=', 'outgoing'), ('state', '!=', 'cancel')])
def _get_doc_model(self):
return 'stock.picking'
def _get_doc_types(self):
return "transfers"
def _get_moves(self, docs):
return docs.move_ids.filtered(lambda m: m.product_id.type == 'product' and m.state != 'cancel')
def _get_extra_domain(self, docs):
return [('picking_id', 'not in', docs.ids)]
def _get_formatted_scheduled_date(self, source):
""" Unfortunately different source record types have different field names for their "Scheduled Date"
Therefore an extendable method is needed.
"""
if source._name == 'stock.picking':
return format_date(self.env, source.scheduled_date)
return False
def action_assign(self, move_ids, qtys, in_ids):
""" Assign picking move(s) [i.e. link] to other moves (i.e. make them MTO)
:param move_id ids: the ids of the moves to make MTO
:param qtys list: the quantities that are being assigned to the move_ids (in same order as move_ids)
:param in_ids ids: the ids of the moves that are to be assigned to move_ids
"""
outs = self.env['stock.move'].browse(move_ids)
# Split outs with only part of demand assigned to prevent reservation problems later on.
# We do this first so we can create their split moves in batch
out_to_new_out = OrderedDict()
new_move_vals = []
for out, qty_to_link in zip(outs, qtys):
if float_compare(out.product_qty, qty_to_link, precision_rounding=out.product_id.uom_id.rounding) == 1:
new_move = out._split(out.product_qty - qty_to_link)
if new_move:
new_move[0]['reservation_date'] = out.reservation_date
new_move_vals += new_move
out_to_new_out[out.id] = self.env['stock.move']
new_outs = self.env['stock.move'].create(new_move_vals)
# don't do action confirm to avoid creating additional unintentional reservations
new_outs.write({'state': 'confirmed'})
for i, k in enumerate(out_to_new_out.keys()):
out_to_new_out[k] = new_outs[i]
for out, qty_to_link, ins in zip(outs, qtys, in_ids):
potential_ins = self.env['stock.move'].browse(ins)
if out.id in out_to_new_out:
new_out = out_to_new_out[out.id]
if potential_ins[0].state != 'done' and out.quantity:
# let's assume if 1 of the potential_ins isn't done, then none of them are => we are only assigning the not-reserved
# qty and the new move should have all existing reserved quants (i.e. move lines) assigned to it
out.move_line_ids.move_id = new_out
elif potential_ins[0].state == 'done' and out.quantity > qty_to_link:
# let's assume if 1 of the potential_ins is done, then all of them are => we can link them to already reserved moves, but we
# need to make sure the reserved qtys still match the demand amount the move (we're assigning).
out.move_line_ids.move_id = new_out
assigned_amount = 0
for move_line_id in new_out.move_line_ids:
if assigned_amount + move_line_id.quantity_product_uom > qty_to_link:
new_move_line = move_line_id.copy({'quantity': 0})
new_move_line.quantity = move_line_id.quantity
move_line_id.quantity = out.product_id.uom_id._compute_quantity(qty_to_link - assigned_amount, out.product_uom, rounding_method='HALF-UP')
new_move_line.quantity -= out.product_id.uom_id._compute_quantity(move_line_id.quantity_product_uom, out.product_uom, rounding_method='HALF-UP')
move_line_id.move_id = out
assigned_amount += move_line_id.quantity_product_uom
if float_compare(assigned_amount, qty_to_link, precision_rounding=out.product_id.uom_id.rounding) == 0:
break
for in_move in reversed(potential_ins):
quantity_remaining = in_move.product_qty - sum(in_move.move_dest_ids.mapped('product_qty'))
if in_move.product_id != out.product_id or float_compare(0, quantity_remaining, precision_rounding=in_move.product_id.uom_id.rounding) >= 0:
# in move is already completely linked (e.g. during another assign click) => don't count it again
potential_ins = potential_ins[1:]
continue
linked_qty = min(in_move.product_qty, qty_to_link)
in_move.move_dest_ids |= out
self._action_assign(in_move, out)
out.procure_method = 'make_to_order'
quantity_remaining -= linked_qty
qty_to_link -= linked_qty
if float_is_zero(qty_to_link, precision_rounding=out.product_id.uom_id.rounding):
break # we have satistfied the qty_to_link
(outs | new_outs)._recompute_state()
# always try to auto-assign to prevent another move from reserving the quant if incoming move is done
self.env['stock.move'].browse(move_ids)._action_assign()
def action_unassign(self, move_id, qty, in_ids):
""" Unassign moves [i.e. unlink] from a move (i.e. make non-MTO)
:param move_id id: the id of the move to make non-MTO
:param qty float: the total quantity that is being unassigned from move_id
:param in_ids ids: the ids of the moves that are to be unassigned from move_id
"""
out = self.env['stock.move'].browse(move_id)
ins = self.env['stock.move'].browse(in_ids)
amount_unassigned = 0
for in_move in ins:
if out.id not in in_move.move_dest_ids.ids:
continue
in_move.move_dest_ids -= out
self._action_unassign(in_move, out)
amount_unassigned += min(qty, in_move.product_qty)
if float_compare(qty, amount_unassigned, precision_rounding=out.product_id.uom_id.rounding) <= 0:
break
if out.move_orig_ids and out.state != 'done':
# annoying use cases where we need to split the out move:
# 1. batch reserved + individual picking unreserved
# 2. moves linked from backorder generation
total_still_linked = sum(out.move_orig_ids.mapped('product_qty'))
new_move_vals = out._split(total_still_linked)
if new_move_vals:
new_move_vals[0]['procure_method'] = 'make_to_order'
new_move_vals[0]['reservation_date'] = out.reservation_date
new_out = self.env['stock.move'].create(new_move_vals)
# don't do action confirm to avoid creating additional unintentional reservations
new_out.write({'state': 'confirmed'})
out.move_line_ids.move_id = new_out
(out | new_out)._compute_quantity()
if new_out.quantity > new_out.product_qty:
# extra reserved amount goes to no longer linked out
reserved_amount_to_remain = new_out.quantity - new_out.product_qty
for move_line_id in new_out.move_line_ids:
if reserved_amount_to_remain <= 0:
break
if move_line_id.quantity_product_uom > reserved_amount_to_remain:
new_move_line = move_line_id.copy({'quantity': 0})
new_move_line.quantity = out.product_id.uom_id._compute_quantity(move_line_id.quantity_product_uom - reserved_amount_to_remain, move_line_id.product_uom_id, rounding_method='HALF-UP')
move_line_id.quantity -= new_move_line.quantity
move_line_id.move_id = out
break
else:
move_line_id.move_id = out
reserved_amount_to_remain -= move_line_id.quantity_product_uom
(out | new_out)._compute_quantity()
out.move_orig_ids = False
new_out._recompute_state()
out.procure_method = 'make_to_stock'
out._recompute_state()
return True
def _action_assign(self, in_move, out_move):
""" For extension purposes only """
return
def _action_unassign(self, in_move, out_move):
""" For extension purposes only """
return
def _format_html_docs(self, docs):
""" Format docs to be sent in an html request. """
return [{
'id': doc.id,
'name': doc.display_name,
'state': doc.state,
'display_state': dict(doc._fields['state']._description_selection(self.env)).get(doc.state),
} for doc in docs] if docs else docs
def _format_html_sources_to_date(self, sources_to_dates):
""" Format sources_to_formatted_scheduled_date to be sent in an html request. """
return {str(source): date for (source, date) in sources_to_dates.items()}
def _format_html_sources_to_lines(self, sources_to_lines):
""" Format sources_to_lines to be sent in an html request, while adding an index for OWL's t-foreach. """
return {
str(source): [{**line, 'index': i, 'move_out_id': line['move_out'].id} for i, line in enumerate(lines)]
for source, lines in sources_to_lines.items()
}
def _format_html_sources_info(self, sources_to_lines):
""" Format used info from sources of sources_to_lines to be sent in an html request. """
return {str(source): [self._format_html_source(s, s._name == 'stock.picking')for s in source] for source in sources_to_lines.keys()}
def _format_html_source(self, source, is_picking=False):
""" Format used info from a single source to be sent in an html request. """
formatted = {
'id': source.id,
'model': source._name,
'name': source.display_name,
}
if is_picking:
formatted.update({
'priority': source.priority,
'partner_id': source.partner_id.id if source.partner_id else False,
'partner_name': source.partner_id.name if source.partner_id else False,
})
return formatted