From 53cc41016fe105bd8462efc6a0f0dfba34df2489 Mon Sep 17 00:00:00 2001 From: "Tiffany Chang (tic)" Date: Thu, 29 Jul 2021 14:32:43 +0000 Subject: [PATCH] [IMP] mrp, product, {purchase_, sale_}stock{_picking_batch}: add reception report New reception report added for non-outgoing transfers. This is intended to support easier stock allocations by allowing dynamic MTO assignment/unassignment, e.g. if we have an incoming transfer with products we want to assign to an existing outgoing transfer, then we can use the report to create a MTO link to the specific outgoing moves. To support this assignment flow, the report also allows printing of labels to place on the product so stock workers know which transfer/MO the product has been assigned to. Expected use case is when a product is purchased to fulfil a sale. Demo data has been added so reception report can be immediately seen/used for this flow. Implementation Notes: - Report has been made flexible to work with batch transfers. - Only done moves can be assigned to moves that already have quants reserved (prevents undesired behavior + this makes sense logically) - Confirmed (+ Done and everything inbetween) moves can be assigned to any confirmed moves that are not already assigned. - Report does not affect quant reservation/unreservation at all. It works only with linking moves (i.e. move_orig_id/move_dest_id) so move/transfer linkage traceability is stored within db (i.e. this isn't possible with quants) Limitations: To keep code simple for now, this assignment flow will break in certain cases: 1. Done amount of an assigned move is less than the Demand amount (linked move will not autoreserve correctly when assigned move is validated, same issue already occurs in multi-step transfer). 2. If a linked move is unreserved after its assigned move is validated, then another move can reserve its quants and its assignment link will not be accurate. 3. Already reserved SNs + assign move, may lead to mismatching SNs between report and what's actually reserved. 4. Changing a linked move's Demand amount after a move is assigned to it. 5. Potential moves to assign to are only checked for being in same warehouse, not in a matching source location to destination location. User is expected to make this match on their own. Task: 2500844 ENT PR: odoo/enterprise#18268 Upgrade PR: odoo/upgrade#2731 closes odoo/odoo#70669 Signed-off-by: Arnold Moyaux --- addons/mrp/__manifest__.py | 2 + addons/mrp/report/__init__.py | 1 + addons/mrp/report/mrp_report_views_main.xml | 10 + addons/mrp/report/report_stock_reception.py | 14 + addons/mrp/report/report_stock_reception.xml | 23 ++ .../static/src/js/report_stock_reception.js | 16 + addons/product/data/product_demo.xml | 9 + addons/product/report/product_reports.xml | 15 + .../data/purchase_stock_demo.xml | 4 + addons/sale_stock/data/sale_order_demo.xml | 25 ++ addons/stock/__manifest__.py | 4 + addons/stock/models/res_config_settings.py | 2 + addons/stock/models/stock_move.py | 2 +- addons/stock/models/stock_picking.py | 21 ++ addons/stock/report/__init__.py | 1 + addons/stock/report/report_stock_reception.py | 294 +++++++++++++++ .../stock/report/report_stock_reception.xml | 165 +++++++++ addons/stock/report/stock_report_views.xml | 10 + addons/stock/security/stock_security.xml | 10 + .../static/src/js/report_stock_reception.js | 205 +++++++++++ .../src/scss/report_stock_reception.scss | 46 +++ .../static/src/xml/report_stock_reception.xml | 16 + addons/stock/tests/test_report.py | 342 ++++++++++++++++++ .../stock/views/res_config_settings_views.xml | 20 + addons/stock/views/stock_picking_views.xml | 5 + .../models/stock_picking_batch.py | 7 + .../views/stock_picking_batch_views.xml | 7 + 27 files changed, 1275 insertions(+), 1 deletion(-) create mode 100644 addons/mrp/report/report_stock_reception.py create mode 100644 addons/mrp/report/report_stock_reception.xml create mode 100644 addons/mrp/static/src/js/report_stock_reception.js create mode 100644 addons/stock/report/report_stock_reception.py create mode 100644 addons/stock/report/report_stock_reception.xml create mode 100644 addons/stock/static/src/js/report_stock_reception.js create mode 100644 addons/stock/static/src/scss/report_stock_reception.scss create mode 100644 addons/stock/static/src/xml/report_stock_reception.xml diff --git a/addons/mrp/__manifest__.py b/addons/mrp/__manifest__.py index e27b3595850..72ee3b72676 100644 --- a/addons/mrp/__manifest__.py +++ b/addons/mrp/__manifest__.py @@ -43,6 +43,7 @@ 'report/mrp_report_bom_structure.xml', 'report/mrp_production_templates.xml', 'report/report_stock_forecasted.xml', + 'report/report_stock_reception.xml', 'report/report_stock_rule.xml', 'report/mrp_zebra_production_templates.xml', ], @@ -68,6 +69,7 @@ 'mrp/static/src/js/mrp_document_kanban_view.js', 'mrp/static/src/js/mrp_should_consume.js', 'mrp/static/src/js/mrp_field_one2many_with_copy.js', + 'mrp/static/src/js/report_stock_reception.js', ], 'web.assets_common': [ 'mrp/static/src/scss/mrp_bom_report.scss', diff --git a/addons/mrp/report/__init__.py b/addons/mrp/report/__init__.py index b2dc5ecce3c..e2f2e2f20e3 100644 --- a/addons/mrp/report/__init__.py +++ b/addons/mrp/report/__init__.py @@ -3,4 +3,5 @@ from . import mrp_report_bom_structure from . import report_stock_forecasted +from . import report_stock_reception from . import report_stock_rule diff --git a/addons/mrp/report/mrp_report_views_main.xml b/addons/mrp/report/mrp_report_views_main.xml index 2f74cf7ccca..5f947480abd 100644 --- a/addons/mrp/report/mrp_report_views_main.xml +++ b/addons/mrp/report/mrp_report_views_main.xml @@ -40,5 +40,15 @@ report + + Order Label + mrp.production + qweb-pdf + mrp.report_reception_report_label_mrp + mrp.report_reception_report_label_mrp + + + report + diff --git a/addons/mrp/report/report_stock_reception.py b/addons/mrp/report/report_stock_reception.py new file mode 100644 index 00000000000..a7cc717bca4 --- /dev/null +++ b/addons/mrp/report/report_stock_reception.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.tools import format_date + + +class ReceptionReport(models.AbstractModel): + _inherit = 'report.stock.report_reception' + + def _get_formatted_scheduled_date(self, source): + if source._name == 'mrp.production': + return format_date(self.env, source.date_planned_start) + return super()._get_formatted_scheduled_date(source) diff --git a/addons/mrp/report/report_stock_reception.xml b/addons/mrp/report/report_stock_reception.xml new file mode 100644 index 00000000000..1ad4cc4dd47 --- /dev/null +++ b/addons/mrp/report/report_stock_reception.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/addons/mrp/static/src/js/report_stock_reception.js b/addons/mrp/static/src/js/report_stock_reception.js new file mode 100644 index 00000000000..5da4a80bf98 --- /dev/null +++ b/addons/mrp/static/src/js/report_stock_reception.js @@ -0,0 +1,16 @@ +/** @odoo-module **/ + +import ReceptionReport from '@stock/js/report_stock_reception'; + +ReceptionReport.include({ + + /** + * @override + */ + _onClickPrintLabel: function (ev) { + return this._super(ev).then(() => { + return this._printLabel(ev, 'mrp.report_reception_report_label_mrp', 'mrp.production'); + }); + }, + +}); diff --git a/addons/product/data/product_demo.xml b/addons/product/data/product_demo.xml index f62f41849df..7051400787b 100644 --- a/addons/product/data/product_demo.xml +++ b/addons/product/data/product_demo.xml @@ -782,6 +782,15 @@ + + + + 1 + 0 + 145 + + + property_product_pricelist diff --git a/addons/product/report/product_reports.xml b/addons/product/report/product_reports.xml index 35e033a9df2..d86685c1c1e 100644 --- a/addons/product/report/product_reports.xml +++ b/addons/product/report/product_reports.xml @@ -63,5 +63,20 @@ product.report_pricelist product.report_pricelist + + + Dymo Label Sheet + + custom + 57 + 32 + Landscape + 0 + 0 + 0 + 0 + + 96 + diff --git a/addons/purchase_stock/data/purchase_stock_demo.xml b/addons/purchase_stock/data/purchase_stock_demo.xml index 4b1af5ae5b8..cd2bb20fcb8 100644 --- a/addons/purchase_stock/data/purchase_stock_demo.xml +++ b/addons/purchase_stock/data/purchase_stock_demo.xml @@ -14,6 +14,10 @@ + + + + diff --git a/addons/sale_stock/data/sale_order_demo.xml b/addons/sale_stock/data/sale_order_demo.xml index 7683ce780cb..d2f056ea049 100644 --- a/addons/sale_stock/data/sale_order_demo.xml +++ b/addons/sale_stock/data/sale_order_demo.xml @@ -114,10 +114,35 @@ 140.00 + + + + + + + + + + + + + + + + + + + 10 + + 199.00 + + + diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index a8365efb1e4..3c8ec0f8ae1 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -30,6 +30,7 @@ 'report/report_stock_forecasted.xml', 'report/report_stock_quantity.xml', + 'report/report_stock_reception.xml', 'report/stock_report_views.xml', 'report/report_package_barcode.xml', 'report/report_lot_barcode.xml', @@ -94,6 +95,7 @@ 'web/static/src/legacy/scss/views.scss', 'web/static/src/legacy/scss/graph_view.scss', 'stock/static/src/scss/report_stock_forecasted.scss', + 'stock/static/src/scss/report_stock_reception.scss', 'stock/static/src/scss/report_stock_rule.scss', ], 'web.assets_common': [ @@ -106,6 +108,7 @@ 'stock/static/src/js/inventory_singleton_list_controller.js', 'stock/static/src/js/inventory_singleton_list_view.js', 'stock/static/src/js/report_stock_forecasted.js', + 'stock/static/src/js/report_stock_reception.js', 'stock/static/src/js/stock_orderpoint_list_controller.js', 'stock/static/src/js/stock_orderpoint_list_model.js', 'stock/static/src/js/stock_orderpoint_list_view.js', @@ -132,6 +135,7 @@ 'stock/static/src/xml/popover_widget.xml', 'stock/static/src/xml/forecast_widget.xml', 'stock/static/src/xml/report_stock_forecasted.xml', + 'stock/static/src/xml/report_stock_reception.xml', 'stock/static/src/xml/stock_orderpoint.xml', 'stock/static/src/xml/stock_traceability_report_backend.xml', ], diff --git a/addons/stock/models/res_config_settings.py b/addons/stock/models/res_config_settings.py index 1fcea3c12da..87879c9d324 100644 --- a/addons/stock/models/res_config_settings.py +++ b/addons/stock/models/res_config_settings.py @@ -43,6 +43,8 @@ class ResConfigSettings(models.TransientModel): 'Storage Categories', implied_group='stock.group_stock_storage_categories') annual_inventory_month = fields.Selection(related='company_id.annual_inventory_month', readonly=False) annual_inventory_day = fields.Integer(related='company_id.annual_inventory_day', readonly=False) + group_stock_reception_report = fields.Boolean("Reception Report", implied_group='stock.group_reception_report') + group_stock_auto_reception_report = fields.Boolean("Show Reception Report at Validation", implied_group='stock.group_auto_reception_report') @api.onchange('group_stock_multi_locations') def _onchange_group_stock_multi_locations(self): diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index a97b4f7723d..cc61a412d9c 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -114,7 +114,7 @@ class StockMove(models.Model): 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, + default='make_to_stock', required=True, copy=False, 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, " diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 6276889bbc0..515aa86beaa 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -954,6 +954,24 @@ class Picking(models.Model): pickings_to_backorder = self pickings_not_to_backorder.with_context(cancel_backorder=True)._action_done() pickings_to_backorder.with_context(cancel_backorder=False)._action_done() + + if self.user_has_groups('stock.group_reception_report') \ + and self.user_has_groups('stock.group_auto_reception_report') \ + and self.filtered(lambda p: p.picking_type_id.code != 'outgoing'): + lines = self.move_lines.filtered(lambda m: m.product_id.type == 'product' and m.state != 'cancel' and m.quantity_done and not m.move_dest_ids) + if lines: + # don't show reception report if all already assigned/nothing to assign + wh_location_ids = self.env['stock.location'].search([('id', 'child_of', self.picking_type_id.warehouse_id.view_location_id.id), ('location_id.usage', '!=', 'supplier')]).ids + if self.env['stock.move'].search([ + ('state', 'in', ['confirmed', 'partially_available', 'waiting', 'assigned']), + ('product_qty', '>', 0), + ('location_id', 'in', wh_location_ids), + ('move_orig_ids', '=', False), + ('picking_id', 'not in', self.ids), + ('product_id', 'in', lines.product_id.ids)], limit=1): + action = self.action_view_reception_report() + action['context'] = {'default_picking_ids': self.ids} + return action return True def action_set_quantities_to_reservation(self): @@ -1400,6 +1418,9 @@ class Picking(models.Model): action['domain'] = [('picking_id', 'in', self.ids)] return action + def action_view_reception_report(self): + return self.env["ir.actions.actions"]._for_xml_id("stock.stock_reception_action") + def _attach_sign(self): """ Render the delivery report in pdf and attach it to the picking in `self`. """ self.ensure_one() diff --git a/addons/stock/report/__init__.py b/addons/stock/report/__init__.py index 9d26ba485b7..0219be6ca22 100644 --- a/addons/stock/report/__init__.py +++ b/addons/stock/report/__init__.py @@ -3,5 +3,6 @@ from . import report_stock_forecasted from . import report_stock_quantity +from . import report_stock_reception from . import report_stock_rule from . import stock_traceability diff --git a/addons/stock/report/report_stock_reception.py b/addons/stock/report/report_stock_reception.py new file mode 100644 index 00000000000..7115f3eb69a --- /dev/null +++ b/addons/stock/report/report_stock_reception.py @@ -0,0 +1,294 @@ +# -*- 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_values(self, docids, data=None): + ''' This report is flexibly designed to work with both individual and batch pickings. + ''' + docids = self.env.context.get('default_picking_ids', docids) + pickings = self.env['stock.picking'].search([('id', 'in', docids), ('picking_type_code', '!=', 'outgoing'), ('state', '!=', 'cancel')]) + picking_states = pickings.mapped('state') + # unsupported cases + if not pickings: + msg = _("No transfers selected or a delivery order selected") + elif 'done' in picking_states and len(set(picking_states)) > 1: + pickings = False + msg = _("This report cannot be used for done and not done transfers at the same time") + if not pickings: + 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_lines = pickings.move_lines.filtered(lambda m: m.product_id.type == 'product' and m.state != 'cancel') + assigned_moves = move_lines.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_lines: + 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 move.picking_id.immediate_transfer: + # 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_done, 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 = pickings[0].picking_type_id.warehouse_id + wh_location_ids = self.env['stock.location'].search([('id', 'child_of', warehouse.view_location_id.id), ('location_id.usage', '!=', 'supplier')]).ids + + allowed_states = ['confirmed', 'partially_available', 'waiting'] + if 'done' in picking_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), + ('picking_id', 'not in', pickings.ids), + ('product_id', 'in', + [p.id for p in list(product_to_qty_to_assign.keys()) + list(product_to_qty_draft.keys())]), + ], + 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: + # only show pickings or MOs, no SOs + source = out.picking_id if out.picking_id else out._get_source_document() + if not source: + continue + + qty_to_reserve = out.product_qty + product_uom = out.product_id.uom_id + if 'done' not in picking_states and out.state == 'partially_available': + qty_to_reserve -= out.product_uom._compute_quantity(out.reserved_availability, product_uom) + moves_in_ids = [] + qty_done = 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(qty_done + 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 - qty_done + move_in_qty -= qty_to_add + qty_done += 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, qty_done, precision_rounding=product_uom.rounding) == 0: + break + + if not float_is_zero(qty_done, precision_rounding=product_uom.rounding): + sources_to_lines[source].append(self._prepare_report_line(qty_done, product_id, out, source, 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, qty_done, 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 - qty_done) + sources_to_lines[source].append(self._prepare_report_line(to_expect, product_id, out, source, 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.picking_id if out_move.picking_id else out_move._get_source_document() + if not source: + continue + 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, 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) + + return { + 'data': data, + 'doc_ids': docids, + 'doc_model': 'stock.picking', + 'sources_to_lines': sources_to_lines, + 'precision': self.env['decimal.precision'].precision_get('Product Unit of Measure'), + 'pickings': pickings, + '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_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_vals += out._split(out.product_qty - qty_to_link) + 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.reserved_availability: + # 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.reserved_availability > 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.product_qty > qty_to_link: + new_move_line = move_line_id.copy({'product_uom_qty': 0, 'qty_done': 0}) + new_move_line.product_uom_qty = move_line_id.product_uom_qty + move_line_id.product_uom_qty = out.product_id.uom_id._compute_quantity(qty_to_link - assigned_amount, out.product_uom, rounding_method='HALF-UP') + new_move_line.product_uom_qty -= out.product_id.uom_id._compute_quantity(move_line_id.product_qty, out.product_uom, rounding_method='HALF-UP') + move_line_id.move_id = out + assigned_amount += move_line_id.product_qty + 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 + 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 + 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: + # annoying use case: batch reserved + individual picking unreserved, need to split the out move + new_move_vals = out._split(amount_unassigned) + if new_move_vals: + new_move_vals[0]['procure_method'] = 'make_to_order' + 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_reserved_availability() + if new_out.reserved_availability > new_out.product_qty: + # extra reserved amount goes to no longer linked out + reserved_amount_to_remain = new_out.reserved_availability - 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.product_qty > reserved_amount_to_remain: + new_move_line = move_line_id.copy({'product_uom_qty': 0, 'qty_done': 0}) + new_move_line.product_uom_qty = out.product_id.uom_id._compute_quantity(move_line_id.product_qty - reserved_amount_to_remain, move_line_id.product_uom_id, rounding_method='HALF-UP') + move_line_id.product_uom_qty -= new_move_line.product_uom_qty + new_move_line.move_id = out + break + else: + move_line_id.move_id = out + reserved_amount_to_remain -= move_line_id.product_qty + (out | new_out)._compute_reserved_availability() + out.move_orig_ids = False + new_out._recompute_state() + out.procure_method = 'make_to_stock' + out._recompute_state() diff --git a/addons/stock/report/report_stock_reception.xml b/addons/stock/report/report_stock_reception.xml new file mode 100644 index 00000000000..21614217410 --- /dev/null +++ b/addons/stock/report/report_stock_reception.xml @@ -0,0 +1,165 @@ + + + + + + + + + Reception Report + stock.picking + qweb-html + stock.report_reception + + + + Reception Report + reception_report + + + + + + diff --git a/addons/stock/report/stock_report_views.xml b/addons/stock/report/stock_report_views.xml index cff99953af5..adfb4b3d1d0 100644 --- a/addons/stock/report/stock_report_views.xml +++ b/addons/stock/report/stock_report_views.xml @@ -176,5 +176,15 @@ report + + Picking Label + stock.picking + qweb-pdf + stock.report_reception_report_label + stock.report_reception_report_label + + + report + diff --git a/addons/stock/security/stock_security.xml b/addons/stock/security/stock_security.xml index ad72b4b74bb..f66a8a8d834 100644 --- a/addons/stock/security/stock_security.xml +++ b/addons/stock/security/stock_security.xml @@ -68,6 +68,16 @@ Manage Storage Categories + + + Use Reception Report + + + + + Display Reception Report at Validation + + diff --git a/addons/stock/static/src/js/report_stock_reception.js b/addons/stock/static/src/js/report_stock_reception.js new file mode 100644 index 00000000000..d27e59fbfc8 --- /dev/null +++ b/addons/stock/static/src/js/report_stock_reception.js @@ -0,0 +1,205 @@ +/** @odoo-module **/ + +import clientAction from 'report.client_action'; +import core from 'web.core'; + +const qweb = core.qweb; + +const ReceptionReport = clientAction.extend({ + /** + * @override + */ + init: function (parent, action, options) { + this._super(...arguments); + this.context = Object.assign(action.context || {}, { + active_ids: action.context.default_picking_ids, + }); + this.report_name = `stock.report_reception`; + this.report_url = `/report/html/${this.report_name}/?context=${JSON.stringify(this.context)}`; + this._title = action.name; + }, + + /** + * @override + */ + start: function () { + return Promise.all([ + this._super(...arguments), + ]).then(() => { + this._renderButtons(); + }); + }, + + /** + * @override + */ + on_attach_callback: function () { + this._super(); + this.iframe.addEventListener("load", + () => this._bindAdditionalActionHandlers(), + { once: true } + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders extra report buttons in control panel + */ + _renderButtons: function () { + this.$buttons.append(qweb.render('reception_report_buttons', {})); + this.$buttons.on('click', '.o_report_reception_assign', this._onClickAssign.bind(this)); + this.$buttons.on('click', '.o_print_label', this._onClickPrintLabel.bind(this)); + this.controlPanelProps.cp_content = { + $buttons: this.$buttons, + }; + }, + + /** + * Bind additional + + + diff --git a/addons/stock/tests/test_report.py b/addons/stock/tests/test_report.py index 693fa874bdb..9ab332fc25d 100644 --- a/addons/stock/tests/test_report.py +++ b/addons/stock/tests/test_report.py @@ -1173,3 +1173,345 @@ class TestReports(TestReportsCommon): self.assertEqual(lines[1]['document_out'].id, delivery_at_confirm.id) self.assertEqual(lines[2]['document_out'].id, delivery_by_date_priority.id) self.assertEqual(lines[3]['document_out'].id, delivery_manual.id) + + def test_report_reception_1_one_receipt(self): + """ Create 2 deliveries and 1 receipt where some of the products being received + can be reserved for the deliveries. Check that the reception report correctly + shows these corresponding potential allocations + correctly reserves incoming moves + when reserve button is pushed. + """ + product2 = self.env['product.product'].create({ + 'name': 'Extra Product', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + product3 = self.env['product.product'].create({ + 'name': 'Unpopular Product', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + # Creates some deliveries for reception report to match against + delivery_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + delivery_form.partner_id = self.partner + delivery_form.picking_type_id = self.picking_type_out + with delivery_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 5 + with delivery_form.move_ids_without_package.new() as move_line: + move_line.product_id = product2 + move_line.product_uom_qty = 10 + delivery1 = delivery_form.save() + delivery1.action_confirm() + + delivery_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + delivery_form.partner_id = self.partner + delivery_form.picking_type_id = self.picking_type_out + with delivery_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 2 + delivery2 = delivery_form.save() + delivery2.action_confirm() + + # Create a receipt + receipt_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + receipt_form.partner_id = self.partner + receipt_form.picking_type_id = self.picking_type_in + with receipt_form.move_ids_without_package.new() as move_line: + # incoming qty greater than total (2 moves) outgoing amount => 2 report lines, each = outgoing qty + move_line.product_id = self.product + move_line.product_uom_qty = 15 + with receipt_form.move_ids_without_package.new() as move_line: + # outgoing qty greater than incoming amount => report line = incoming qty + move_line.product_id = product2 + move_line.product_uom_qty = 5 + with receipt_form.move_ids_without_package.new() as move_line: + # not outgoing => shouldn't appear in report + move_line.product_id = product3 + move_line.product_uom_qty = 5 + receipt = receipt_form.save() + + # check that reception report has correct number of deliveries/outgoing moves + # but the quantities aren't available for assignment yet (i.e. can link as chained moves) + report = self.env['report.stock.report_reception'] + report_values = report._get_report_values(docids=[receipt.id]) + sources_to_lines = report_values['sources_to_lines'] + self.assertEqual(len(sources_to_lines), 2, "The report has wrong number of outgoing pickings.") + all_lines = [] + for dummy, lines in sources_to_lines.items(): + for line in lines: + self.assertFalse(line['is_qty_assignable'], "The receipt IS DRAFT => its move quantities ARE NOT available to assign.") + all_lines.append(line) + self.assertEqual(len(all_lines), 3, "The report has wrong number of outgoing moves.") + # we expect this order based on move creation + self.assertEqual(all_lines[0]['quantity'], 5, "The first move has wrong incoming qty.") + self.assertEqual(all_lines[0]['product']['id'], self.product.id, "The first move has wrong incoming product to assign.") + self.assertEqual(all_lines[1]['quantity'], 5, "The second move has wrong incoming qty.") + self.assertEqual(all_lines[1]['product']['id'], product2.id, "The second move has wrong incoming product to assign.") + self.assertEqual(all_lines[2]['quantity'], 2, "The last move has wrong incoming qty.") + self.assertEqual(all_lines[2]['product']['id'], self.product.id, "The third move has wrong incoming product to assign.") + + # check that report correctly realizes outgoing moves can be linked when receipt is done + receipt.action_confirm() + for move in receipt.move_lines: + move.quantity_done = move.product_uom_qty + receipt.button_validate() + report_values = report._get_report_values(docids=[receipt.id]) + + sources_to_lines = report_values['sources_to_lines'] + all_lines = [] + move_ids = [] + qtys = [] + in_ids = [] + for dummy, lines in sources_to_lines.items(): + for line in lines: + self.assertTrue(line['is_qty_assignable'], "The receipt IS DONE => all of its move quantities ARE assignable") + all_lines.append(line) + move_ids.append(line['move_out'].id) + qtys.append(line['quantity']) + in_ids += line['move_ins'] + # line quantities should be the same when receipt is done compared to when it was draft + self.assertEqual(len(all_lines), 3, "The report has wrong number of outgoing moves.") + self.assertEqual(all_lines[0]['quantity'], 5, "The first move has wrong incoming qty to reserve.") + self.assertEqual(all_lines[0]['product']['id'], self.product.id, "The first move has wrong product to reserve.") + self.assertEqual(all_lines[1]['quantity'], 5, "The second move has wrong incoming qty to reserve.") + self.assertEqual(all_lines[1]['product']['id'], product2.id, "The second move has wrong product to reserve.") + self.assertEqual(all_lines[2]['quantity'], 2, "The last move has wrong incoming qty to reserve.") + self.assertEqual(all_lines[2]['product']['id'], self.product.id, "The third move has wrong product to reserve.") + + # check that report assign button works correctly + report.action_assign(move_ids, qtys, in_ids) + self.assertEqual(len(receipt.move_lines[0].move_dest_ids.ids), 2, "Demand qty of first and last moves should now be linked to incoming.") + self.assertEqual(len(receipt.move_lines[1].move_dest_ids.ids), 1, "Demand qty of second move should now be linked to incoming.") + self.assertEqual(len(receipt.move_lines[2].move_dest_ids.ids), 0, "product3 should have no moves linked to it.") + self.assertEqual(len(delivery1.move_lines.filtered(lambda m: m.product_id == product2)), 2, "product2 outgoing move should be split between linked and non-linked quantities.") + + def test_report_reception_2_two_receipts(self): + """ Create 1 delivery and 2 receipts where the products being received + can be reserved for the delivery. Check that the reception report correctly + shows corresponding potential allocations when receipts have differing states. + """ + # Creates delivery for reception report to match against + delivery_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + delivery_form.partner_id = self.partner + delivery_form.picking_type_id = self.picking_type_out + with delivery_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 100 + delivery = delivery_form.save() + delivery.action_confirm() + + # Create 2 receipts and check its reception report values + receipt_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + receipt_form.partner_id = self.partner + receipt_form.picking_type_id = self.picking_type_in + with receipt_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 5 + receipt1 = receipt_form.save() + + receipt_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + receipt_form.partner_id = self.partner + receipt_form.picking_type_id = self.picking_type_in + with receipt_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 3 + receipt2 = receipt_form.save() + + # check that report correctly merges not draft incoming quantities + report = self.env['report.stock.report_reception'] + report_values = report._get_report_values(docids=[receipt1.id, receipt2.id]) + self.assertEqual(len(report_values['pickings']), 2, "There should be 2 receipts to assign from in this report") + sources_to_lines = report_values['sources_to_lines'] + self.assertEqual(len(sources_to_lines), 1, "The report has wrong number of outgoing pickings.") + all_lines = list(sources_to_lines.values())[0] + self.assertEqual(len(all_lines), 1, "The report has wrong number of outgoing move lines.") + self.assertFalse(all_lines[0]['is_qty_assignable'], "The receipt IS NOT done => its move quantities ARE NOT available to reserve (i.e. done).") + self.assertEqual(all_lines[0]['quantity'], 8, "The move has wrong incoming qty.") + + # check that report splits assignable and non-assignable quantities when 1 receipt is draft and other is confirmed + receipt1.action_confirm() + for move in receipt1.move_lines: + move.quantity_done = move.product_uom_qty + report_values = report._get_report_values(docids=[receipt1.id, receipt2.id]) + + sources_to_lines = report_values['sources_to_lines'] + all_lines = list(sources_to_lines.values())[0] + # line quantities depends on done vs not done incoming quantities => should be 2 lines now + self.assertEqual(len(all_lines), 2, "The report has wrong number of lines (1 assignable + 1 not).") + self.assertEqual(all_lines[0]['quantity'], 5, "The first move has wrong incoming qty to assign.") + self.assertTrue(all_lines[0]['is_qty_assignable'], "1 receipt is done => should have 1 reservable move.") + self.assertEqual(all_lines[1]['quantity'], 3, "The second move has wrong (expected) incoming qty.") + self.assertFalse(all_lines[1]['is_qty_assignable'], "1 receipt is draft => should have 1 non-assignable move.") + + # check that report doesn't allow done and non-done moves at same time + receipt1.button_validate() + reason = report._get_report_values(docids=[receipt1.id, receipt2.id])['reason'] + self.assertEqual(reason, "This report cannot be used for done and not done transfers at the same time", "empty report reason not shown") + + def test_report_reception_3_multiwarehouse(self): + """ Check that reception report respects same warehouse for + receipts and deliveries. + """ + # Warehouse config. + wh_2 = self.env['stock.warehouse'].create({ + 'name': 'Other Warehouse', + 'code': 'OTHER', + }) + picking_type_out_2 = self.env['stock.picking.type'].search([ + ('code', '=', 'outgoing'), + ('warehouse_id', '=', wh_2.id), + ]) + + # Creates delivery in warehouse2 + delivery_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + delivery_form.partner_id = self.partner + delivery_form.picking_type_id = picking_type_out_2 + with delivery_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 100 + delivery = delivery_form.save() + delivery.action_confirm() + + # Create a receipt in warehouse1 + receipt_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + receipt_form.partner_id = self.partner + receipt_form.picking_type_id = self.picking_type_in + with receipt_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 15 + receipt = receipt_form.save() + + report = self.env['report.stock.report_reception'] + report_values = report._get_report_values(docids=[receipt.id]) + self.assertEqual(len(report_values['sources_to_lines']), 0, "The receipt and delivery are in different warehouses => no moves to link to should be found.") + + def test_report_reception_4_pick_pack(self): + """ Check that reception report ignores outgoing moves that are not beginning of chain + """ + + warehouse = self.env['stock.warehouse'].search([('lot_stock_id', '=', self.stock_location.id)], limit=1) + warehouse.write({'delivery_steps': 'pick_pack_ship'}) + + ship_move = self.env['stock.move'].create({ + 'name': 'The ship move', + 'product_id': self.product.id, + 'product_uom_qty': 5.0, + 'product_uom': self.product.uom_id.id, + 'location_id': warehouse.wh_output_stock_loc_id.id, + 'location_dest_id': self.env.ref('stock.stock_location_customers').id, + 'warehouse_id': warehouse.id, + 'picking_type_id': warehouse.out_type_id.id, + 'procure_method': 'make_to_order', + 'state': 'draft', + }) + + # create chained pick/pack moves to test with + ship_move._assign_picking() + ship_move._action_confirm() + pack_move = ship_move.move_orig_ids[0] + pick_move = pack_move.move_orig_ids[0] + + self.assertEqual(pack_move.state, 'waiting', "Pack move wasn't created...") + self.assertEqual(pick_move.state, 'confirmed', "Pick move wasn't created...") + + receipt_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + receipt_form.partner_id = self.partner + receipt_form.picking_type_id = self.picking_type_in + with receipt_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = 15 + receipt = receipt_form.save() + + report = self.env['report.stock.report_reception'] + report_values = report._get_report_values(docids=[receipt.id]) + self.assertEqual(len(report_values['sources_to_lines']), 1, "There should only be 1 line (pick move)") + + def test_report_reception_5_move_splitting(self): + """ Check the complicated use cases of correct move splitting when assigning/unassigning when: + 1. Qty to assign is less than delivery qty demand + 2. Delivery already has some reserved quants + """ + incoming_qty = 4 + outgoing_qty = 10 + qty_in_stock = outgoing_qty - incoming_qty + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product.id, + 'location_id': self.stock_location.id, + 'inventory_quantity': qty_in_stock + }).action_apply_inventory() + + # create delivery + receipt + delivery_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + delivery_form.picking_type_id = self.picking_type_out + with delivery_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = outgoing_qty + delivery = delivery_form.save() + delivery.action_confirm() + + receipt_form = Form(self.env['stock.picking'].with_context( + force_detailed_view=True + ), view='stock.view_picking_form') + receipt_form.partner_id = self.partner + receipt_form.picking_type_id = self.picking_type_in + with receipt_form.move_ids_without_package.new() as move_line: + move_line.product_id = self.product + move_line.product_uom_qty = incoming_qty + receipt = receipt_form.save() + receipt.action_confirm() + + self.assertEqual(len(delivery.move_ids_without_package), 1) + report = self.env['report.stock.report_reception'] + + # ------------------- + # check report assign + # ------------------- + report.action_assign(delivery.move_ids_without_package.ids, [incoming_qty], receipt.move_ids_without_package.ids) + mto_move = delivery.move_ids_without_package.filtered(lambda m: m.procure_method == 'make_to_order') + non_mto_move = delivery.move_ids_without_package - mto_move + + # check that delivery move splits correctly when receipt move is assigned to it + self.assertEqual(len(delivery.move_ids_without_package), 2, "Delivery moves should have split into assigned + not assigned") + self.assertEqual(len(delivery.move_ids_without_package.mapped('move_orig_ids')), 1, "Only 1 delivery + 1 receipt move should be assigned") + self.assertEqual(len(receipt.move_ids_without_package.mapped('move_dest_ids')), 1, "Receipt move should remain unsplit") + + # check that assigned (MTO) move is correctly created + self.assertEqual(len(mto_move), 1, "Only 1 delivery move should be MTO") + self.assertEqual(mto_move.product_uom_qty, incoming_qty, "Incorrect quantity split for MTO move") + self.assertEqual(mto_move.reserved_availability, 0, "Receipt is not done => assigned move can't have a reserved qty") + self.assertEqual(mto_move.state, 'waiting', "MTO move state not correctly set") + + # check that non-assigned move has correct values + self.assertEqual(non_mto_move.product_uom_qty, outgoing_qty - incoming_qty, "Incorrect quantity split for non-MTO move") + self.assertEqual(non_mto_move.reserved_availability, qty_in_stock, "Reserved qty not correctly linked to non-MTO move") + self.assertEqual(non_mto_move.state, 'assigned', "Fully reserved move has not correctly set state") + + # --------------------- + # check report unassign + # --------------------- + report.action_unassign([mto_move.id], incoming_qty, receipt.move_ids_without_package.ids) + self.assertEqual(mto_move.product_uom_qty, incoming_qty, "Move quantities should be unchanged") + self.assertEqual(mto_move.procure_method, 'make_to_stock', "Procure method not correctly reset") + self.assertEqual(mto_move.state, 'confirmed', "Move state not correctly reset (to non-MTO state)") diff --git a/addons/stock/views/res_config_settings_views.xml b/addons/stock/views/res_config_settings_views.xml index 49555d7175f..032c9211921 100644 --- a/addons/stock/views/res_config_settings_views.xml +++ b/addons/stock/views/res_config_settings_views.xml @@ -79,6 +79,26 @@ +
+
+ +
+
+
+

Barcode

diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index aeb4564bca9..74a83644881 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -261,6 +261,11 @@ class="oe_stat_button" icon="fa-cubes" attrs="{'invisible': [('has_packages', '=', False)]}"/>