diff --git a/sale_order_line_multi_warehouse/README.rst b/sale_order_line_multi_warehouse/README.rst index 0770415bae8..b2522ef3a28 100644 --- a/sale_order_line_multi_warehouse/README.rst +++ b/sale_order_line_multi_warehouse/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - =============================== Sale Order Line Multi Warehouse =============================== @@ -17,7 +13,7 @@ Sale Order Line Multi Warehouse .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github @@ -32,7 +28,7 @@ Sale Order Line Multi Warehouse |badge1| |badge2| |badge3| |badge4| |badge5| -This module allows to select multiple warehouses in sale order lines so the sale order is split into multiple pickings depending on the warehouses selected in the lines. +This module allows to select multiple warehouses in sale order lines so the sale order is split into multiple pickings depending on the warehouses selected in the lines. It also adds a new widget to sale order lines when the multiwarehouse options in sale orders are enabled in order to check the stock in each warehouse. **Table of contents** @@ -70,6 +66,8 @@ Usage * Once the sale order is validated, the order will be split into multiple pickings, one for each warehouse selected in the warehouse distribution lines. +* To open the multi warehouse widget in a sale order line, you need to click on the bar chart in a sale order line. Please, note that this widget replaces the widget added by the sale_stock module when the multi warehouse options in sale orders are enabled. + * **IMPORTANT: In case this module is uninstalled, the warehouse distribution lines will be lost.** Known issues / Roadmap diff --git a/sale_order_line_multi_warehouse/__manifest__.py b/sale_order_line_multi_warehouse/__manifest__.py index 37289e42560..d25acc0890e 100644 --- a/sale_order_line_multi_warehouse/__manifest__.py +++ b/sale_order_line_multi_warehouse/__manifest__.py @@ -23,4 +23,9 @@ "views/res_config_settings_views.xml", "wizard/so_multi_warehouse_change_wizard_views.xml", ], + "assets": { + "web.assets_backend": [ + "sale_order_line_multi_warehouse/static/src/**/*", + ], + }, } diff --git a/sale_order_line_multi_warehouse/models/sale_order_line.py b/sale_order_line_multi_warehouse/models/sale_order_line.py index dbdf1bf5bb8..97456e4a35a 100644 --- a/sale_order_line_multi_warehouse/models/sale_order_line.py +++ b/sale_order_line_multi_warehouse/models/sale_order_line.py @@ -28,6 +28,12 @@ class SaleOrderLine(models.Model): comodel_name="stock.warehouse", related="order_id.suitable_warehouse_ids", ) + qty_by_warehouse = fields.Binary( + compute="_compute_qty_by_warehouse", exportable=False + ) + display_qty_by_warehouse_widget = fields.Boolean( + compute="_compute_display_qty_by_warehouse_widget" + ) @api.constrains("sale_order_line_warehouse_ids") def _check_warehouses(self): @@ -41,6 +47,174 @@ def _check_warehouses(self): ): raise ValidationError(_("Only one warehouse per line allowed")) + def _compute_qty_to_deliver(self): + ret_vals = super()._compute_qty_to_deliver() + for line in self.filtered("allow_sale_multi_warehouse"): + line.display_qty_widget = False + return ret_vals + + @api.depends( + "product_type", + "qty_delivered", + "state", + "move_ids", + "product_uom", + "allow_sale_multi_warehouse", + ) + def _compute_display_qty_by_warehouse_widget(self): + for line in self: + display_qty_by_warehouse_widget = False + if ( + line.allow_sale_multi_warehouse + and line.product_type == "product" + and line.product_uom + and line.qty_to_deliver > 0 + and ( + line.state in ["draft", "sent"] + or (line.state == "sale" and line.move_ids) + ) + ): + display_qty_by_warehouse_widget = True + line.display_qty_by_warehouse_widget = display_qty_by_warehouse_widget + + @api.depends( + "product_id", + "product_uom_qty", + "product_uom", + "order_id.commitment_date", + "move_ids", + "move_ids.forecast_expected_date", + "move_ids.forecast_availability", + "sale_order_line_warehouse_ids", + ) + def _compute_qty_by_warehouse(self): + for line in self: + qty_by_warehouse = {} + warehouses = [] + for warehouse in line.suitable_warehouse_ids: + warehouses.append(line._get_qty_by_warehouse_vals(warehouse)) + qty_by_warehouse["warehouses"] = warehouses + forecasted_issue = any( + warehouse.get("forecasted_issue") for warehouse in warehouses + ) + qty_by_warehouse["forecasted_issue"] = forecasted_issue + line.qty_by_warehouse = qty_by_warehouse + + # Based on _compute_qty_at_date method in sale.order.line + def _get_qty_by_warehouse_vals(self, warehouse): + self.ensure_one() + scheduled_date = self.order_id.commitment_date or self._expected_date() + moves = self.move_ids | self.env["stock.move"].browse( + self.move_ids._rollup_move_origs() + ) + moves = moves.filtered( + lambda m: m.product_id == self.product_id + and m.state not in ("cancel", "done") + and m.warehouse_id == warehouse + ) + + # qty_available_today + qty_available_today = 0 + for move in moves: + qty_available_today += move.product_uom._compute_quantity( + move.reserved_availability, self.product_uom + ) + + # forecast_expected_date + forecast_expected_date = False + if moves: + forecast_expected_date = max(moves.mapped("forecast_expected_date")) + + # free_qty_today + free_qty_today = 0.0 + if self.state == "sale": + for move in moves: + free_qty_today += move.product_id.uom_id._compute_quantity( + move.forecast_availability, self.product_uom + ) + elif self.state in ["draft", "sent"]: + free_qty_today = self.product_id.with_context( + to_date=scheduled_date, warehouse=warehouse.id + ).free_qty + + # virtual_available_at_date + virtual_available_at_date = self.product_id.with_context( + to_date=scheduled_date, warehouse=warehouse.id + ).virtual_available + + # qty_to_deliver + to_deliver_from_warehouse = ( + sum( + self.sale_order_line_warehouse_ids.filtered( + lambda a: a.warehouse_id == warehouse + ).mapped("product_uom_qty") + ) + or 0.0 + ) + # taken from _compute_qty_delivered in sale.order.line in module + # sale_stock + qty_delivered = 0.0 + qty_to_deliver = 0.0 + if self.qty_delivered_method == "stock_move": + outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves() + for move in outgoing_moves.filtered(lambda a: a.warehouse_id == warehouse): + if move.state != "done": + continue + qty_delivered += move.product_uom._compute_quantity( + move.quantity_done, + self.product_uom, + rounding_method="HALF-UP", + ) + for move in incoming_moves.filtered(lambda a: a.warehouse_id == warehouse): + if move.state != "done": + continue + qty_delivered -= move.product_uom._compute_quantity( + move.quantity_done, + self.product_uom, + rounding_method="HALF-UP", + ) + qty_to_deliver = to_deliver_from_warehouse - qty_delivered + + # will_be_fulfilled + if self.state in ["sale", "done"]: + will_be_fulfilled = free_qty_today >= qty_to_deliver + else: + will_be_fulfilled = virtual_available_at_date >= qty_to_deliver + + # forecasted_issue + forecasted_issue = False + if ( + self.state in ["draft", "sent"] + and not will_be_fulfilled + and not self.is_mto + ): + forecasted_issue = True + elif not will_be_fulfilled or ( + forecast_expected_date and forecast_expected_date > scheduled_date + ): + forecasted_issue = True + + # format forecast_expected_date formatted + forecast_expected_date_str = "" + lang = self.env.context.get("lang") or "en_US" + date_format = self.env["res.lang"]._lang_get(lang).date_format + if forecast_expected_date: + forecast_expected_date_str = forecast_expected_date.strftime(date_format) + + return { + "warehouse": warehouse.id, + "warehouse_name": warehouse.name, + "qty_available_today": qty_available_today, + "virtual_available_at_date": virtual_available_at_date, + "free_qty_today": free_qty_today, + "qty_to_deliver": qty_to_deliver, + "will_be_fulfilled": will_be_fulfilled, + "forecast_expected_date": forecast_expected_date, + "scheduled_date": scheduled_date, + "forecast_expected_date_str": forecast_expected_date_str, + "forecasted_issue": forecasted_issue, + } + def write(self, values): # Do not assign quantity to warehouse distribution lines # if this write is triggered by write method in diff --git a/sale_order_line_multi_warehouse/readme/DESCRIPTION.rst b/sale_order_line_multi_warehouse/readme/DESCRIPTION.rst index 10617d95e37..59e298763ff 100644 --- a/sale_order_line_multi_warehouse/readme/DESCRIPTION.rst +++ b/sale_order_line_multi_warehouse/readme/DESCRIPTION.rst @@ -1 +1 @@ -This module allows to select multiple warehouses in sale order lines so the sale order is split into multiple pickings depending on the warehouses selected in the lines. +This module allows to select multiple warehouses in sale order lines so the sale order is split into multiple pickings depending on the warehouses selected in the lines. It also adds a new widget to sale order lines when the multiwarehouse options in sale orders are enabled in order to check the stock in each warehouse. diff --git a/sale_order_line_multi_warehouse/readme/USAGE.rst b/sale_order_line_multi_warehouse/readme/USAGE.rst index 029cf363aea..e899b3142a3 100644 --- a/sale_order_line_multi_warehouse/readme/USAGE.rst +++ b/sale_order_line_multi_warehouse/readme/USAGE.rst @@ -13,4 +13,6 @@ * Once the sale order is validated, the order will be split into multiple pickings, one for each warehouse selected in the warehouse distribution lines. +* To open the multi warehouse widget in a sale order line, you need to click on the bar chart in a sale order line. Please, note that this widget replaces the widget added by the sale_stock module when the multi warehouse options in sale orders are enabled. + * **IMPORTANT: In case this module is uninstalled, the warehouse distribution lines will be lost.** diff --git a/sale_order_line_multi_warehouse/static/description/index.html b/sale_order_line_multi_warehouse/static/description/index.html index c3ed980a871..7af7aa69421 100644 --- a/sale_order_line_multi_warehouse/static/description/index.html +++ b/sale_order_line_multi_warehouse/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Sale Order Line Multi Warehouse -
+
+

Sale Order Line Multi Warehouse

- - -Odoo Community Association - -
-

Sale Order Line Multi Warehouse

-

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

-

This module allows to select multiple warehouses in sale order lines so the sale order is split into multiple pickings depending on the warehouses selected in the lines.

+

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

This module allows to select multiple warehouses in sale order lines so the sale order is split into multiple pickings depending on the warehouses selected in the lines. It also adds a new widget to sale order lines when the multiwarehouse options in sale orders are enabled in order to check the stock in each warehouse.

Table of contents

    @@ -392,7 +387,7 @@

    Sale Order Line Multi Warehouse

-

Configuration

+

Configuration

To activate the sale orders multi-warehouse options you need to:

  1. Go to Inventory > Configuration > Settings.
  2. @@ -405,7 +400,7 @@

    Configuration

-

Usage

+

Usage

  • To split a sale order line into multiple warehouses you need to click on the tree graph icon in a sale order line. A popup will open, where warehouses and quantities can be selected. Only the warehouses set in the “Alternative Warehouses” field in the warehouse set in the sale order can be selected. Sale order line quantity and its warehouse distributions line quantities are synchronized as follows:
    @@ -436,18 +431,19 @@

    Usage

  • When the multi warehouse options in sale order lines are enabled, changing the general sale order warehouse needs to be done through a wizard, located in the “Other Info” tab, in the “Delivery” section. The button is only visible for users in group “Technical / Manage Multiple Warehouses”.
  • A sale order line cannot have multiple warehouse distribution lines related to the same warehouse.
  • Once the sale order is validated, the order will be split into multiple pickings, one for each warehouse selected in the warehouse distribution lines.
  • +
  • To open the multi warehouse widget in a sale order line, you need to click on the bar chart in a sale order line. Please, note that this widget replaces the widget added by the sale_stock module when the multi warehouse options in sale orders are enabled.
  • IMPORTANT: In case this module is uninstalled, the warehouse distribution lines will be lost.
-

Known issues / Roadmap

+

Known issues / Roadmap

  • The warehouse distribution lines display could be improved in the future so it looks similar to the pop up used for analytic distribution.
  • Module sale_procurement_group_by_line should not be used along with this module as it provides the base to split sale order lines depending on different criteria, which might make sale_order_line_multi_warehouse module malfunction.
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -455,22 +451,22 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-
diff --git a/sale_order_line_multi_warehouse/static/src/widgets/qty_at_date_by_warehouse_widget.esm.js b/sale_order_line_multi_warehouse/static/src/widgets/qty_at_date_by_warehouse_widget.esm.js new file mode 100644 index 00000000000..d57bfff5299 --- /dev/null +++ b/sale_order_line_multi_warehouse/static/src/widgets/qty_at_date_by_warehouse_widget.esm.js @@ -0,0 +1,85 @@ +/** @odoo-module **/ + +import {formatDateTime} from "@web/core/l10n/dates"; +import {localization} from "@web/core/l10n/localization"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {usePopover} from "@web/core/popover/popover_hook"; + +const {Component, EventBus} = owl; + +export class QtyAtDateByWarehousePopover extends Component { + setup() { + this.actionService = useService("action"); + this.formatData(this.props.record.data.qty_by_warehouse); + } + + formatData(qty_by_warehouse) { + const warehouses = JSON.parse(JSON.stringify(qty_by_warehouse)); + this.warehouses = warehouses; + } + + openForecast(warehouse) { + this.actionService.doAction( + "stock.stock_replenishment_product_product_action", + { + additionalContext: { + active_model: "product.product", + active_id: this.props.record.data.product_id[0], + warehouse: warehouse.warehouse, + move_to_match_ids: this.props.record.data.move_ids.records.map( + (record) => record.data.id + ), + sale_line_to_match_id: this.props.record.data.id, + }, + } + ); + } +} + +QtyAtDateByWarehousePopover.template = + "sale_order_line_multi_warehouse.QtyDetailByWarehousePopOver"; + +export class QtyAtDateByWarehouseWidget extends Component { + setup() { + this.bus = new EventBus(); + this.popover = usePopover(); + this.closePopover = null; + this.calcData = {}; + this.warehouses = JSON.parse( + JSON.stringify(this.props.record.data.qty_by_warehouse) + ); + } + + updateCalcData() { + // Popup specific data + const {data} = this.props.record; + if (!data.scheduled_date) { + return; + } + this.calcData.delivery_date = formatDateTime(data.scheduled_date, { + format: localization.dateFormat, + }); + } + + showPopup(ev) { + this.updateCalcData(); + this.closePopover = this.popover.add( + ev.currentTarget, + this.constructor.components.Popover, + {bus: this.bus, record: this.props.record, calcData: this.calcData}, + { + position: "top", + } + ); + this.bus.addEventListener("close-popover", this.closePopover); + } +} + +QtyAtDateByWarehouseWidget.components = {Popover: QtyAtDateByWarehousePopover}; +QtyAtDateByWarehouseWidget.template = + "sale_order_line_multi_warehouse.qtyAtDateByWarehouse"; + +registry + .category("view_widgets") + .add("qty_at_date_by_warehouse_widget", QtyAtDateByWarehouseWidget); diff --git a/sale_order_line_multi_warehouse/static/src/widgets/qty_at_date_by_warehouse_widget.xml b/sale_order_line_multi_warehouse/static/src/widgets/qty_at_date_by_warehouse_widget.xml new file mode 100644 index 00000000000..4bc8b08b399 --- /dev/null +++ b/sale_order_line_multi_warehouse/static/src/widgets/qty_at_date_by_warehouse_widget.xml @@ -0,0 +1,107 @@ + + diff --git a/sale_order_line_multi_warehouse/tests/test_so_line_multiwarehouse.py b/sale_order_line_multi_warehouse/tests/test_so_line_multiwarehouse.py index 977d9a27410..9dda6ce6610 100644 --- a/sale_order_line_multi_warehouse/tests/test_so_line_multiwarehouse.py +++ b/sale_order_line_multi_warehouse/tests/test_so_line_multiwarehouse.py @@ -1,11 +1,45 @@ # Copyright 2024 Manuel Regidor # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime, timedelta + +from odoo import fields from odoo.exceptions import ValidationError from odoo.tests import TransactionCase class TestSOLineMultiwarehouse(TransactionCase): + + # Distribution of products in warehouses + QUANTITIES = { + "YourCompany": { + "Product-1": 5, + "Product-2": 8, + }, + "Alternative Warehouse-1": { + "Product-1": 6, + "Product-2": 9, + }, + "Alternative Warehouse-2": { + "Product-1": 7, + "Product-2": 10, + }, + } + + # Distribution of quantity in used order line + SPLIT_QTY = { + "Product-1": { + "YourCompany": 1, + "Alternative Warehouse-1": 1, + "Alternative Warehouse-2": 1, + }, + "Product-2": { + "YourCompany": 2, + "Alternative Warehouse-1": 2, + "Alternative Warehouse-2": 1, + }, + } + @classmethod def setUpClass(cls): super().setUpClass() @@ -764,3 +798,333 @@ def test_multi_warehouse_change_wizard(self): warehouse_change_wiz.write({"new_warehouse_id": self.warehouse.id}) with self.assertRaises(ValidationError): warehouse_change_wiz.change_warehouse() + + def replenish_qty(self, product, qty, warehouse, date=False): + picking = self.env["stock.picking"].create( + { + "picking_type_id": warehouse.in_type_id.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": warehouse.lot_stock_id.id, + "scheduled_date": date, + } + ) + self.env["stock.move"].create( + { + "name": product.name, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": qty, + "picking_id": picking.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + "date": date, + } + ) + picking.action_assign() + + def test_display_widget_draft(self): + sale = self.create_sale_order() + self.assertEqual(sale.state, "draft") + for line in sale.order_line: + self.assertTrue(line.display_qty_by_warehouse_widget) + + def test_display_widget_confirmed(self): + sale = self.create_sale_order() + sale.action_confirm() + self.assertTrue(sale.state in ["sale", "done"]) + for line in sale.order_line: + self.assertTrue(line.display_qty_by_warehouse_widget) + + def test_warehouse_qty_draft(self): + sale = self.create_sale_order() + self.split_order_lines(sale) + + # product_1 split in warehouses + # 1u. -> warehouse + # 1u. -> alternative_warehouse_1 + # 1u. -> anternative_warehouse_2 + + # product_2 split in warehouses + # 2u. -> warehouse + # 2u. -> alternative_warehouse_1 + # 1u. -> anternative_warehouse_2 + + for line in sale.order_line: + warehouses = line.qty_by_warehouse.get("warehouses") + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertFalse(forecasted_issue) + for warehouse in warehouses: + values = self.QUANTITIES.get(warehouse["warehouse_name"]) + self.assertTrue(values) + self.assertEqual( + values.get(line.product_id.name), + warehouse["virtual_available_at_date"], + ) + self.assertEqual( + values.get(line.product_id.name), warehouse["free_qty_today"] + ) + + # 5 extra units of product_1 in warehouse available in 7 days + self.replenish_qty( + self.product_1, 5, self.warehouse, datetime.now() + timedelta(days=7) + ) + + # 5 extra units of product_1 in alternative_warehouse_1 available in 7 days + self.replenish_qty( + self.product_1, + 5, + self.alternative_warehouse_1, + datetime.now() + timedelta(days=7), + ) + + # 5 extra units of product_1 in alternative_warehouse_2 available in 7 days + self.replenish_qty( + self.product_1, + 5, + self.alternative_warehouse_2, + datetime.now() + timedelta(days=7), + ) + + # 5 extra units of product_1 in warehouse available in 7 days + self.replenish_qty( + self.product_2, 5, self.warehouse, datetime.now() + timedelta(days=7) + ) + + # 5 extra units of product_1 in alternative_warehouse_1 available in 7 days + self.replenish_qty( + self.product_2, + 5, + self.alternative_warehouse_1, + datetime.now() + timedelta(days=7), + ) + + # 5 extra units of product_1 in alternative_warehouse_2 available in 7 days + self.replenish_qty( + self.product_2, + 5, + self.alternative_warehouse_2, + datetime.now() + timedelta(days=7), + ) + + sale.write({"commitment_date": datetime.now() + timedelta(days=10)}) + + for line in sale.order_line: + warehouses = line.qty_by_warehouse.get("warehouses") + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertFalse(forecasted_issue) + for warehouse in warehouses: + values = self.QUANTITIES.get(warehouse["warehouse_name"]) + self.assertTrue(values) + self.assertEqual( + values.get(line.product_id.name) + 5, + warehouse["virtual_available_at_date"], + ) + self.assertEqual( + values.get(line.product_id.name), warehouse["free_qty_today"] + ) + + # Each line of the sale order is increased 20 units + for line in sale.order_line: + line.write({"product_uom_qty": line.product_uom_qty + 20}) + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertTrue(forecasted_issue) + + # 20 extra units of product_1 in warehouse available in 8 days + self.replenish_qty( + self.product_1, 20, self.warehouse, datetime.now() + timedelta(days=8) + ) + + # 20 extra units of product_1 in alternative_warehouse_1 available in 8 days + self.replenish_qty( + self.product_1, + 20, + self.alternative_warehouse_1, + datetime.now() + timedelta(days=7), + ) + + # 20 extra units of product_1 in alternative_warehouse_2 available in 8 days + self.replenish_qty( + self.product_1, + 20, + self.alternative_warehouse_2, + datetime.now() + timedelta(days=7), + ) + + # 20 extra units of product_2 in warehouse available in 8 days + self.replenish_qty( + self.product_2, 20, self.warehouse, datetime.now() + timedelta(days=8) + ) + + # 20 extra units of product_2 in alternative_warehouse_1 available in 8 days + self.replenish_qty( + self.product_2, + 20, + self.alternative_warehouse_1, + datetime.now() + timedelta(days=7), + ) + + # 20 extra units of product_2 in alternative_warehouse_2 available in 8 days + self.replenish_qty( + self.product_2, + 20, + self.alternative_warehouse_2, + datetime.now() + timedelta(days=7), + ) + + for line in sale.order_line: + line._compute_qty_by_warehouse() + warehouses = line.qty_by_warehouse.get("warehouses") + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertFalse(forecasted_issue) + for warehouse in warehouses: + values = self.QUANTITIES.get(warehouse["warehouse_name"]) + self.assertTrue(values) + self.assertEqual( + values.get(line.product_id.name) + 25, + warehouse["virtual_available_at_date"], + ) + self.assertEqual( + values.get(line.product_id.name), warehouse["free_qty_today"] + ) + + sale.write({"commitment_date": datetime.now()}) + + for line in sale.order_line: + warehouses = line.qty_by_warehouse.get("warehouses") + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertTrue(forecasted_issue) + for warehouse in warehouses: + values = self.QUANTITIES.get(warehouse["warehouse_name"]) + self.assertTrue(values) + self.assertEqual( + values.get(line.product_id.name), + warehouse["virtual_available_at_date"], + ) + self.assertEqual( + values.get(line.product_id.name), warehouse["free_qty_today"] + ) + + def test_warehouse_qty_confirmed(self): + sale = self.create_sale_order() + self.split_order_lines(sale) + sale.action_confirm() + + # product_1 split in warehouses + # 1u. -> warehouse + # 1u. -> alternative_warehouse_1 + # 1u. -> anternative_warehouse_2 + + # product_2 split in warehouses + # 2u. -> warehouse + # 2u. -> alternative_warehouse_1 + # 1u. -> anternative_warehouse_2 + + for line in sale.order_line: + warehouses = line.qty_by_warehouse.get("warehouses") + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertFalse(forecasted_issue) + for warehouse in warehouses: + qty = self.SPLIT_QTY[line.product_id.name][warehouse["warehouse_name"]] + self.assertTrue(qty) + self.assertEqual(qty, warehouse["qty_available_today"]) + self.assertEqual(qty, warehouse["free_qty_today"]) + + # Each line of the sale order is increased 20 units + for line in sale.order_line: + line.write({"product_uom_qty": line.product_uom_qty + 20}) + + for line in sale.order_line: + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertTrue(forecasted_issue) + warehouses = line.qty_by_warehouse.get("warehouses") + for warehouse in warehouses: + warehouse_line = line.sale_order_line_warehouse_ids.filtered( + lambda a, w=warehouse: w["warehouse"] == a.warehouse_id.id + ) + self.assertTrue(warehouse_line) + if warehouse.get("will_be_fulfilled"): + self.assertEqual( + warehouse_line.product_uom_qty, warehouse["qty_available_today"] + ) + self.assertEqual( + warehouse_line.product_uom_qty, warehouse["free_qty_today"] + ) + else: + self.assertEqual( + warehouse_line.product_uom_qty, + warehouse["qty_available_today"] + - warehouse["virtual_available_at_date"], + ) + self.assertEqual( + warehouse_line.product_uom_qty, + warehouse["free_qty_today"] + - warehouse["virtual_available_at_date"], + ) + + replenish_date = fields.Datetime.now() + timedelta(days=7) + # 20 extra units of product_1 in warehouse available in 8 days + self.replenish_qty(self.product_1, 20, self.warehouse, replenish_date) + # 20 extra units of product_2 in warehouse available in 8 days + self.replenish_qty(self.product_2, 20, self.warehouse, replenish_date) + + # It is necessary to recompute the forecast_information field in moves + sale.order_line.mapped("move_ids")._compute_forecast_information() + + for line in sale.order_line: + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertTrue(forecasted_issue) + warehouses = line.qty_by_warehouse.get("warehouses") + for warehouse in warehouses: + self.assertTrue(warehouse["will_be_fulfilled"]) + warehouse_line = line.sale_order_line_warehouse_ids.filtered( + lambda a, w=warehouse: w["warehouse"] == a.warehouse_id.id + ) + self.assertTrue(warehouse_line) + if warehouse.get("warehouse") == self.warehouse.id: + self.assertEqual( + replenish_date, warehouse["forecast_expected_date"] + ) + self.assertTrue(warehouse["forecast_expected_date_str"]) + else: + self.assertEqual( + warehouse_line.product_uom_qty, warehouse["qty_available_today"] + ) + self.assertEqual( + warehouse_line.product_uom_qty, warehouse["free_qty_today"] + ) + self.assertFalse(warehouse["forecast_expected_date"]) + self.assertFalse(warehouse["forecast_expected_date_str"]) + + # Commitment date is moved forward, so quantity will be repplenished + # by then + sale.write({"commitment_date": datetime.now() + timedelta(days=8)}) + + for line in sale.order_line: + forecasted_issue = line.qty_by_warehouse.get("forecasted_issue") + self.assertFalse(forecasted_issue) + warehouses = line.qty_by_warehouse.get("warehouses") + for warehouse in warehouses: + warehouse_line = line.sale_order_line_warehouse_ids.filtered( + lambda a, w=warehouse: w["warehouse"] == a.warehouse_id.id + ) + self.assertTrue(warehouse_line) + if warehouse.get("warehouse") == self.warehouse.id: + self.assertEqual( + replenish_date, warehouse["forecast_expected_date"] + ) + self.assertTrue(warehouse["forecast_expected_date_str"]) + self.assertEqual( + self.QUANTITIES.get(warehouse["warehouse_name"]).get( + line.product_id.name + ), + warehouse["qty_available_today"], + ) + else: + self.assertEqual( + warehouse_line.product_uom_qty, warehouse["qty_available_today"] + ) + self.assertEqual( + warehouse_line.product_uom_qty, warehouse["free_qty_today"] + ) + self.assertFalse(warehouse["forecast_expected_date"]) + self.assertFalse(warehouse["forecast_expected_date_str"]) diff --git a/sale_order_line_multi_warehouse/views/sale_order_views.xml b/sale_order_line_multi_warehouse/views/sale_order_views.xml index 43e27bcb7d8..7be860bc282 100644 --- a/sale_order_line_multi_warehouse/views/sale_order_views.xml +++ b/sale_order_line_multi_warehouse/views/sale_order_views.xml @@ -31,6 +31,15 @@ widget="many2many_tags" attrs="{'column_invisible': [('parent.allow_sale_multi_warehouse', '=', False)]}" /> + + + + + +