# -*- 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