From 28dddcbbb13c1b32f478d594080f6f665a3ed438 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 28 Nov 2024 11:54:16 +0100 Subject: [PATCH] [ADD] delivery_roulier_picking_batch --- delivery_roulier_picking_batch/README.rst | 98 ++++ delivery_roulier_picking_batch/__init__.py | 1 + .../__manifest__.py | 22 + .../models/__init__.py | 3 + .../models/stock_picking.py | 123 +++++ .../models/stock_picking_batch.py | 160 +++++++ .../models/stock_quant_package.py | 22 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 8 + .../readme/USAGE.md | 10 + .../static/description/index.html | 441 ++++++++++++++++++ .../tests/__init__.py | 1 + .../test_delivery_roulier_picking_batch.py | 422 +++++++++++++++++ .../views/stock_picking_batch_views.xml | 65 +++ .../addons/delivery_roulier_picking_batch | 1 + setup/delivery_roulier_picking_batch/setup.py | 6 + 16 files changed, 1384 insertions(+) create mode 100644 delivery_roulier_picking_batch/README.rst create mode 100644 delivery_roulier_picking_batch/__init__.py create mode 100644 delivery_roulier_picking_batch/__manifest__.py create mode 100644 delivery_roulier_picking_batch/models/__init__.py create mode 100644 delivery_roulier_picking_batch/models/stock_picking.py create mode 100644 delivery_roulier_picking_batch/models/stock_picking_batch.py create mode 100644 delivery_roulier_picking_batch/models/stock_quant_package.py create mode 100644 delivery_roulier_picking_batch/readme/CONTRIBUTORS.md create mode 100644 delivery_roulier_picking_batch/readme/DESCRIPTION.md create mode 100644 delivery_roulier_picking_batch/readme/USAGE.md create mode 100644 delivery_roulier_picking_batch/static/description/index.html create mode 100644 delivery_roulier_picking_batch/tests/__init__.py create mode 100644 delivery_roulier_picking_batch/tests/test_delivery_roulier_picking_batch.py create mode 100644 delivery_roulier_picking_batch/views/stock_picking_batch_views.xml create mode 120000 setup/delivery_roulier_picking_batch/odoo/addons/delivery_roulier_picking_batch create mode 100644 setup/delivery_roulier_picking_batch/setup.py diff --git a/delivery_roulier_picking_batch/README.rst b/delivery_roulier_picking_batch/README.rst new file mode 100644 index 0000000000..b3c86e2315 --- /dev/null +++ b/delivery_roulier_picking_batch/README.rst @@ -0,0 +1,98 @@ +============================== +Delivery Roulier Picking Batch +============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3874042f9be1a0b3f52c99bbcaf8cde90bc384969de0112059d1fffd216be41c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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%2Fdelivery--carrier-lightgray.png?logo=github + :target: https://github.com/OCA/delivery-carrier/tree/14.0/delivery_roulier_picking_batch + :alt: OCA/delivery-carrier +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/delivery-carrier-14-0/delivery-carrier-14-0-delivery_roulier_picking_batch + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/delivery-carrier&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to generate a unique delivery label/tracking for a +whole batch of pickings. + +The batch pickings operations will be grouped in a single delivery +package if they are not already in a delivery package. In case of a +batch with multiple packages, a label per package will be created. + +This module is only compatible with ``delivery_roulier`` carriers. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Create a picking batch from +``Inventory > Operations > Batch Transfers``, add some pickings to it +and then enter a valid roulier compatible carrier in the batch +``Additional Info`` notebook page. + +Then validate the batch and a delivery label will be generated for each +package in the batch. If there's some operations that are not in a +package, a new package will be created for them. + +A tracking number will be generated for each package and the tracking +will be available by clicking the ``Tracking`` smart button. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/delivery-carrier `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/delivery_roulier_picking_batch/__init__.py b/delivery_roulier_picking_batch/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/delivery_roulier_picking_batch/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/delivery_roulier_picking_batch/__manifest__.py b/delivery_roulier_picking_batch/__manifest__.py new file mode 100644 index 0000000000..f34567ca78 --- /dev/null +++ b/delivery_roulier_picking_batch/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Delivery Roulier Picking Batch", + "version": "14.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Use roulier in batch picking", + "category": "Warehouse", + "depends": [ + "delivery_roulier", + "stock_picking_batch", + ], + "website": "https://github.com/OCA/delivery-carrier", + "data": [ + "views/stock_picking_batch_views.xml", + ], + "demo": [], + "installable": True, + "license": "AGPL-3", +} diff --git a/delivery_roulier_picking_batch/models/__init__.py b/delivery_roulier_picking_batch/models/__init__.py new file mode 100644 index 0000000000..34ef67e5bd --- /dev/null +++ b/delivery_roulier_picking_batch/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_picking +from . import stock_quant_package +from . import stock_picking_batch diff --git a/delivery_roulier_picking_batch/models/stock_picking.py b/delivery_roulier_picking_batch/models/stock_picking.py new file mode 100644 index 0000000000..fadf14e161 --- /dev/null +++ b/delivery_roulier_picking_batch/models/stock_picking.py @@ -0,0 +1,123 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from functools import reduce + +from odoo import _, models +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def _is_batch_roulier(self): + # Check if the picking is part of a batch with a roulier carrier + self.ensure_one() + return ( + self.batch_id + and self.batch_id.carrier_id + and self.batch_id.carrier_id._is_roulier() + ) + + def send_to_shipper(self): + self.ensure_one() + if self._is_batch_roulier(): + # We are in a batch with a roulier carrier + # We need to send unsent packages + packages = self.package_ids - self.batch_id.sent_package_ids + if not packages: + # Nothing to send + return + + # First sanity checks + # Check that all package pickings have the same sender/receiver: + for package in packages: + package_pickings = ( + self.env["stock.move.line"] + .search( + [ + "|", + ("result_package_id", "=", package.id), + ("package_id", "=", package.id), + ] + ) + .mapped("picking_id") + ) + + # Set carrier on pickings + package_pickings.write({"carrier_id": self.carrier_id.id}) + + # Check sender/receiver uniformity + for kind in ("sender", "receiver"): + addresses = reduce( + lambda x, y: x | y, + ( + getattr(package_picking, f"_get_{kind}")() + or self.env["res.partner"] + for package_picking in package_pickings + ), + ) + if not addresses: + raise UserError( + _( + "Can't determine %(kind)s address for pickings: %(pickings)s" + ) + % { + "kind": kind, + "pickings": ", ".join(package_pickings.mapped("name")), + } + ) + if len(addresses) > 1: + raise UserError( + _( + "Multiple %(kind)s addresses found for pickings: %(pickings)s" + ) + % { + "kind": kind, + "pickings": ", ".join(package_pickings.mapped("name")), + } + ) + + # Send packages + res = self.batch_id.carrier_id.send_shipping(self)[0] + # Mark packages as sent (for use in _roulier_generate_labels) + self.batch_id.sent_package_ids |= packages + # Update tracking number + if res["tracking_number"]: + self.batch_id.carrier_tracking_ref = ";".join( + [ + tracking + for tracking in ( + self.batch_id.carrier_tracking_ref, + res["tracking_number"], + ) + if tracking + ] + ) + return + + return super().send_to_shipper() + + def _roulier_generate_labels(self): + if self._is_batch_roulier(): + label_info = [] + for picking in self: + # Generate labels only for unsent packages + packages = picking.package_ids - picking.batch_id.sent_package_ids + label_info.append(packages._generate_labels(picking)) + return label_info + + return super()._roulier_generate_labels() + + def get_shipping_label_values(self, label): + self.ensure_one() + if self._is_batch_roulier(): + # Attach the label to the batch instead of the picking + return { + "name": label["name"], + "res_id": self.batch_id.id, + "res_model": "stock.picking.batch", + "datas": label["file"], + "file_type": label["file_type"], + } + return super().get_shipping_label_values(label) diff --git a/delivery_roulier_picking_batch/models/stock_picking_batch.py b/delivery_roulier_picking_batch/models/stock_picking_batch.py new file mode 100644 index 0000000000..e244298f46 --- /dev/null +++ b/delivery_roulier_picking_batch/models/stock_picking_batch.py @@ -0,0 +1,160 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare + + +class StockPickingBatch(models.Model): + _inherit = "stock.picking.batch" + + def _get_default_weight_uom(self): + return self.env[ + "product.template" + ]._get_weight_uom_name_from_ir_config_parameter() + + carrier_price = fields.Float(string="Shipping Cost") + delivery_type = fields.Selection(related="carrier_id.delivery_type", readonly=True) + carrier_id = fields.Many2one( + "delivery.carrier", string="Carrier", check_company=True + ) + weight = fields.Float( + compute="_compute_weight", + digits="Stock Weight", + store=True, + help="Total weight of the products in the picking.", + compute_sudo=True, + ) + carrier_tracking_ref = fields.Char(string="Tracking Reference", copy=False) + carrier_tracking_url = fields.Char( + string="Tracking URL", compute="_compute_carrier_tracking_url" + ) + weight_uom_name = fields.Char( + string="Weight unit of measure label", + compute="_compute_weight_uom_name", + readonly=True, + default=_get_default_weight_uom, + ) + sent_package_ids = fields.One2many( + "stock.quant.package", + "batch_id", + string="Packages", + ) + + @api.constrains("carrier_id") + def _check_carrier_id_is_roulier(self): + for batch in self: + if batch.carrier_id and not batch.carrier_id._is_roulier(): + raise UserError(_("Only Roulier carrier is supported")) + + def _compute_weight_uom_name(self): + for package in self: + package.weight_uom_name = self.env[ + "product.template" + ]._get_weight_uom_name_from_ir_config_parameter() + + @api.depends("carrier_id", "carrier_tracking_ref") + def _compute_carrier_tracking_url(self): + for batch in self: + batch.carrier_tracking_url = ( + ( + # Similar flawed logic as in delivery_roulier + batch.picking_ids.package_ids[0]._get_tracking_link() + if batch.carrier_tracking_ref + and len(batch.picking_ids.package_ids) > 0 + else False + ) + if batch.carrier_id and batch.carrier_id._is_roulier() + else False + ) + + @api.depends("move_ids") + def _compute_weight(self): + for batch in self: + batch.weight = sum( + move.weight for move in batch.move_ids if move.state != "cancel" + ) + + def cancel_shipment(self): + for batch in self: + batch.carrier_id.cancel_shipment(self) + msg = "Shipment %s cancelled" % batch.carrier_tracking_ref + batch.message_post(body=msg) + batch.carrier_tracking_ref = False + batch.sent_package_ids = [(5, 0, 0)] + + def action_done(self): + if not self.carrier_id or not ( + self.carrier_id.integration_level == "rate_and_ship" + and self.picking_type_id.code != "incoming" + ): + return super().action_done() + + self.ensure_one() + pickings = self.picking_ids.filtered( + lambda picking: picking.state not in ("cancel", "done") + ) + if pickings.carrier_id - self.carrier_id: + raise UserError( + _("Pickings %(pickings)s already have a different carrier") + % { + "pickings": ", ".join( + pickings.filtered( + lambda p: p.carrier_id and p.carrier_id != self.carrier_id + ).mapped("name") + ) + } + ) + pickings.write({"carrier_id": self.carrier_id.id}) + # Delivery Roulier works with packages, so we need to generate a package + # if it doesn't exist, this is simalar to action_put_in_pack but without + # the checks + picking_move_lines = self.move_line_ids + move_line_ids = picking_move_lines.filtered( + lambda ml: float_compare( + ml.qty_done, 0.0, precision_rounding=ml.product_uom_id.rounding + ) + > 0 + and not ml.result_package_id + ) + if not move_line_ids: + move_line_ids = picking_move_lines.filtered( + lambda ml: float_compare( + ml.product_uom_qty, + 0.0, + precision_rounding=ml.product_uom_id.rounding, + ) + > 0 + and float_compare( + ml.qty_done, 0.0, precision_rounding=ml.product_uom_id.rounding + ) + == 0 + ) + if move_line_ids: + move_line_ids.picking_id[0]._put_in_pack(move_line_ids, False) + + return super().action_done() + + def open_website_url(self): + """Open tracking page. + + More than 1 tracking number: display a list of packages + Else open directly the tracking page + """ + self.ensure_one() + if not self.carrier_id or not self.carrier_id._is_roulier(): + return super().open_website_url() + + packages = self.sent_package_ids + if len(packages) == 0: + raise UserError(_("No packages found for this picking")) + elif len(packages) == 1: + return packages.open_website_url() # shortpath + + # display a list of pickings + xmlid = "stock.action_package_view" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["domain"] = [("id", "in", packages.ids)] + action["context"] = {"batch_id": self.id} + return action diff --git a/delivery_roulier_picking_batch/models/stock_quant_package.py b/delivery_roulier_picking_batch/models/stock_quant_package.py new file mode 100644 index 0000000000..e69faf14dc --- /dev/null +++ b/delivery_roulier_picking_batch/models/stock_quant_package.py @@ -0,0 +1,22 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockQuantPackage(models.Model): + _inherit = "stock.quant.package" + + batch_id = fields.Many2one("stock.picking.batch", string="Package sent from batch") + + def _roulier_prepare_attachments(self, picking, response): + attachments = super()._roulier_prepare_attachments(picking, response) + if picking._is_batch_roulier(): + for attachment in attachments: + # We need to change the attachment res_model and res_id for it + # to be linked to the batch instead of the picking + if attachment["res_model"] == "stock.picking": + attachment["res_model"] = "stock.picking.batch" + attachment["res_id"] = picking.batch_id.id + return attachments diff --git a/delivery_roulier_picking_batch/readme/CONTRIBUTORS.md b/delivery_roulier_picking_batch/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..328a37da87 --- /dev/null +++ b/delivery_roulier_picking_batch/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/delivery_roulier_picking_batch/readme/DESCRIPTION.md b/delivery_roulier_picking_batch/readme/DESCRIPTION.md new file mode 100644 index 0000000000..ce976323ff --- /dev/null +++ b/delivery_roulier_picking_batch/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module allows to generate a unique delivery label/tracking for a whole batch of +pickings. + +The batch pickings operations will be grouped in a single delivery package if they are +not already in a delivery package. In case of a batch with multiple packages, a label +per package will be created. + +This module is only compatible with `delivery_roulier` carriers. diff --git a/delivery_roulier_picking_batch/readme/USAGE.md b/delivery_roulier_picking_batch/readme/USAGE.md new file mode 100644 index 0000000000..9d574b8fe1 --- /dev/null +++ b/delivery_roulier_picking_batch/readme/USAGE.md @@ -0,0 +1,10 @@ +Create a picking batch from `Inventory > Operations > Batch Transfers`, add some +pickings to it and then enter a valid roulier compatible carrier in the batch +`Additional Info` notebook page. + +Then validate the batch and a delivery label will be generated for each package in the +batch. If there's some operations that are not in a package, a new package will be +created for them. + +A tracking number will be generated for each package and the tracking will be available +by clicking the `Tracking` smart button. diff --git a/delivery_roulier_picking_batch/static/description/index.html b/delivery_roulier_picking_batch/static/description/index.html new file mode 100644 index 0000000000..075cc58f7e --- /dev/null +++ b/delivery_roulier_picking_batch/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +Delivery Roulier Picking Batch + + + +
+

Delivery Roulier Picking Batch

+ + +

Beta License: AGPL-3 OCA/delivery-carrier Translate me on Weblate Try me on Runboat

+

This module allows to generate a unique delivery label/tracking for a +whole batch of pickings.

+

The batch pickings operations will be grouped in a single delivery +package if they are not already in a delivery package. In case of a +batch with multiple packages, a label per package will be created.

+

This module is only compatible with delivery_roulier carriers.

+

Table of contents

+ +
+

Usage

+

Create a picking batch from +Inventory > Operations > Batch Transfers, add some pickings to it +and then enter a valid roulier compatible carrier in the batch +Additional Info notebook page.

+

Then validate the batch and a delivery label will be generated for each +package in the batch. If there’s some operations that are not in a +package, a new package will be created for them.

+

A tracking number will be generated for each package and the tracking +will be available by clicking the Tracking smart button.

+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/delivery-carrier project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/delivery_roulier_picking_batch/tests/__init__.py b/delivery_roulier_picking_batch/tests/__init__.py new file mode 100644 index 0000000000..5beccfa374 --- /dev/null +++ b/delivery_roulier_picking_batch/tests/__init__.py @@ -0,0 +1 @@ +from . import test_delivery_roulier_picking_batch diff --git a/delivery_roulier_picking_batch/tests/test_delivery_roulier_picking_batch.py b/delivery_roulier_picking_batch/tests/test_delivery_roulier_picking_batch.py new file mode 100644 index 0000000000..08d4b05eb7 --- /dev/null +++ b/delivery_roulier_picking_batch/tests/test_delivery_roulier_picking_batch.py @@ -0,0 +1,422 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo_test_helper import FakeModelLoader + +from odoo import fields, models +from odoo.exceptions import UserError +from odoo.tests.common import SavepointCase + + +def patched_roulier_get(carrier, method, data): + return { + "parcels": [ + { + "reference": f"{parcel['reference']}({parcel['weight']}kg)-parcel_{i}", + "tracking": {"url": "", "number": f"parcel_{parcel['reference']}_{i}"}, + "label": { + "name": "file", + "data": b"dGVzdCBsYWJlbA==", + "type": "zpl2", + }, + "id": i, + } + for i, parcel in enumerate(data["parcels"]) + ], + "annexes": [{"name": "Annex", "type": "txt", "data": b"dGVzdCBhbm5leGU="}], + } + + +class TestDeliveryRoulierPickingBatch(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Setup Fake Roulier: + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + + class FakeDeliveryCarrier(models.Model): + _inherit = "delivery.carrier" + + delivery_type = fields.Selection( + selection_add=[("test", "Test Carrier")], + ondelete={"test": "set default"}, + ) + + class FakeStockQuantPackage(models.Model): + _inherit = "stock.quant.package" + + def _test_get_tracking_link(self): + return "https://test.example.com/parcel/%s" % self.parcel_tracking + + cls.loader.update_registry((FakeDeliveryCarrier, FakeStockQuantPackage)) + + cls.patch_get_carriers_action_available = patch( + "roulier.roulier.get_carriers_action_available", + return_value={"test": ["get_label"]}, + ) + cls.patch_get = patch("roulier.roulier.get", side_effect=patched_roulier_get) + + cls.patch_get_carriers_action_available.start() + cls.patch_get.start() + + delivery_product = cls.env["product.product"].create( + {"name": "test shipping product", "type": "service"} + ) + cls.carrier = cls.env["delivery.carrier"].create( + { + "name": "Test Carrier", + "delivery_type": "test", + "product_id": delivery_product.id, + } + ) + cls.env["carrier.account"].create( + { + "name": "Test Carrier Account", + "delivery_type": "test", + "account": "test", + "password": "test", + } + ) + cls.receiver = cls.env["res.partner"].create( + { + "name": "Carrier label test customer", + "country_id": cls.env.ref("base.fr").id, + "street": "test street", + "street2": "test street2", + "city": "test city", + "phone": "0000000000", + "email": "test@test.example.com", + "zip": "00000", + } + ) + cls.other_receiver = cls.env["res.partner"].create( + { + "name": "Carrier label test customer 2", + "country_id": cls.env.ref("base.fr").id, + "street": "test street2", + "street2": "test street2 2", + "city": "test city2", + "phone": "0000000002", + "email": "test2@test.example.com", + "zip": "00002", + } + ) + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.picking_type_out = cls.env["ir.model.data"].xmlid_to_res_id( + "stock.picking_type_out" + ) + cls.productA = cls.env["product.product"].create( + { + "name": "Product A", + "type": "product", + "categ_id": cls.env.ref("product.product_category_all").id, + "weight": 0.13, + } + ) + cls.productB = cls.env["product.product"].create( + { + "name": "Product B", + "type": "product", + "categ_id": cls.env.ref("product.product_category_all").id, + "weight": 4.25, + } + ) + + cls.picking_client_1 = cls.env["stock.picking"].create( + { + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + "picking_type_id": cls.picking_type_out, + "company_id": cls.env.company.id, + "partner_id": cls.receiver.id, + } + ) + + cls.env["stock.move"].create( + { + "name": cls.productA.name, + "product_id": cls.productA.id, + "product_uom_qty": 10, + "product_uom": cls.productA.uom_id.id, + "picking_id": cls.picking_client_1.id, + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + } + ) + + cls.picking_client_2 = cls.env["stock.picking"].create( + { + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + "picking_type_id": cls.picking_type_out, + "company_id": cls.env.company.id, + "partner_id": cls.receiver.id, + } + ) + + cls.env["stock.move"].create( + { + "name": cls.productB.name, + "product_id": cls.productB.id, + "product_uom_qty": 10, + "product_uom": cls.productA.uom_id.id, + "picking_id": cls.picking_client_2.id, + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + } + ) + + cls.picking_client_3 = cls.env["stock.picking"].create( + { + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + "picking_type_id": cls.picking_type_out, + "company_id": cls.env.company.id, + "partner_id": cls.receiver.id, + } + ) + + cls.env["stock.move"].create( + { + "name": cls.productA.name, + "product_id": cls.productA.id, + "product_uom_qty": 4, + "product_uom": cls.productA.uom_id.id, + "picking_id": cls.picking_client_3.id, + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + } + ) + + cls.env["stock.move"].create( + { + "name": cls.productB.name, + "product_id": cls.productB.id, + "product_uom_qty": 7, + "product_uom": cls.productB.uom_id.id, + "picking_id": cls.picking_client_3.id, + "location_id": cls.stock_location.id, + "location_dest_id": cls.customer_location.id, + } + ) + + cls.batch = cls.env["stock.picking.batch"].create( + { + "name": "Batch 1", + "company_id": cls.env.company.id, + "picking_ids": [ + (4, cls.picking_client_1.id), + (4, cls.picking_client_2.id), + ], + } + ) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + cls.patch_get.stop() + cls.patch_get_carriers_action_available.stop() + super().tearDownClass() + + def get_attachments(self, record): + return self.env["ir.attachment"].search( + [("res_model", "=", record._name), ("res_id", "=", record.id)] + ) + + def confirm_batch(self): + self.env["stock.quant"]._update_available_quantity( + self.productA, self.stock_location, 50.0 + ) + self.env["stock.quant"]._update_available_quantity( + self.productB, self.stock_location, 50.0 + ) + + self.batch.action_confirm() + for ml in ( + self.picking_client_1.move_lines + | self.picking_client_2.move_lines + | self.picking_client_3.move_lines + ): + ml.quantity_done = ml.product_uom_qty + + def test_delivery_roulier_picking_batch_no_packages(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.batch.carrier_id = self.carrier + self.batch.action_done() + # There should now be a pack + self.assertEqual(len(self.batch.sent_package_ids), 1) + self.assertEqual( + self.batch.sent_package_ids, self.batch.picking_ids.package_ids + ) + self.assertAlmostEqual(self.batch.sent_package_ids.weight, 43.8) + self.assertAlmostEqual(self.batch.weight, 43.8) + + def test_delivery_roulier_picking_batch_no_packages_no_carrier(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.batch.action_done() + self.assertFalse(self.batch.picking_ids.package_ids) + self.assertFalse(self.batch.sent_package_ids) + + def test_delivery_roulier_picking_batch_existing_packages(self): + self.confirm_batch() + self.picking_client_1._put_in_pack(self.picking_client_1.move_line_ids, False) + self.picking_client_2._put_in_pack(self.picking_client_2.move_line_ids, False) + self.assertEqual(len(self.batch.picking_ids.package_ids), 2) + + self.assertAlmostEqual(self.picking_client_1.package_ids.weight, 1.3) + self.assertAlmostEqual(self.picking_client_2.package_ids.weight, 42.5) + self.assertAlmostEqual(self.batch.weight, 43.8) + packages = self.batch.picking_ids.package_ids + self.batch.carrier_id = self.carrier + self.batch.action_done() + # There should now be a pack + self.assertEqual(len(self.batch.picking_ids.package_ids), 2) + self.assertEqual(packages, self.batch.picking_ids.package_ids) + self.assertEqual( + self.batch.sent_package_ids, self.batch.picking_ids.package_ids + ) + + def test_delivery_roulier_picking_batch_no_carrier(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.assertFalse(self.get_attachments(self.batch)) + self.batch.action_done() + self.assertFalse(self.batch.picking_ids.package_ids) + self.assertFalse(self.picking_client_1.carrier_tracking_ref) + self.assertFalse(self.picking_client_2.carrier_tracking_ref) + self.assertFalse(self.batch.carrier_tracking_ref) + self.assertFalse(self.batch.carrier_tracking_url) + self.assertFalse(self.get_attachments(self.picking_client_1)) + self.assertFalse(self.get_attachments(self.picking_client_2)) + self.assertFalse(self.get_attachments(self.batch)) + + def test_delivery_roulier_picking_batch_label(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.assertFalse(self.get_attachments(self.batch)) + self.batch.carrier_id = self.carrier + self.batch.action_done() + pkg = self.batch.picking_ids.package_ids + self.assertEqual(len(pkg), 1) + self.assertFalse(self.picking_client_1.carrier_tracking_ref) + self.assertFalse(self.picking_client_2.carrier_tracking_ref) + self.assertEqual(self.batch.carrier_tracking_ref, f"parcel_{pkg.name}_0") + self.assertEqual( + self.batch.carrier_tracking_url, + f"https://test.example.com/parcel/parcel_{pkg.name}_0", + ) + self.assertFalse(self.get_attachments(self.picking_client_1)) + self.assertFalse(self.get_attachments(self.picking_client_2)) + attachments = self.get_attachments(self.batch) + self.assertEqual(len(attachments), 2) + self.assertIn(f"{pkg.name}(43.8kg)-parcel_0.zpl2", attachments.mapped("name")) + self.assertIn(f"{pkg.name}-Annex.txt", attachments.mapped("name")) + + def test_delivery_roulier_picking_batch_multipackage_labels(self): + self.batch.picking_ids |= self.picking_client_3 + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.assertFalse(self.get_attachments(self.batch)) + + self.picking_client_1._put_in_pack( + self.picking_client_1.move_line_ids + | self.picking_client_3.move_line_ids.filtered( + lambda ml: ml.product_id == self.productB + ), + False, + ) + self.assertEqual(len(self.batch.picking_ids.package_ids), 1) + pkg1 = self.batch.picking_ids.package_ids + self.picking_client_2._put_in_pack( + self.picking_client_2.move_line_ids + | self.picking_client_3.move_line_ids.filtered( + lambda ml: ml.product_id == self.productA + ), + False, + ) + self.assertEqual(len(self.batch.picking_ids.package_ids), 2) + pkg2 = self.batch.picking_ids.package_ids - pkg1 + self.assertAlmostEqual(pkg1.weight, 31.05) + self.assertAlmostEqual(pkg2.weight, 43.02) + self.assertAlmostEqual(self.batch.weight, 74.07) + + self.batch.carrier_id = self.carrier + self.batch.action_done() + self.assertEqual(self.batch.picking_ids.package_ids, pkg1 | pkg2) + self.assertFalse(self.picking_client_1.carrier_tracking_ref) + self.assertFalse(self.picking_client_2.carrier_tracking_ref) + self.assertIn( + self.batch.carrier_tracking_ref, + ( + f"parcel_{pkg1.name}_0;parcel_{pkg2.name}_0", + f"parcel_{pkg2.name}_0;parcel_{pkg1.name}_0", + ), + ) + self.assertFalse(self.get_attachments(self.picking_client_1)) + self.assertFalse(self.get_attachments(self.picking_client_2)) + attachments = self.get_attachments(self.batch) + self.assertEqual(len(attachments), 4) + self.assertIn(f"{pkg1.name}(31.05kg)-parcel_0.zpl2", attachments.mapped("name")) + self.assertIn(f"{pkg2.name}(43.02kg)-parcel_0.zpl2", attachments.mapped("name")) + self.assertIn(f"{pkg1.name}-Annex.txt", attachments.mapped("name")) + self.assertIn(f"{pkg2.name}-Annex.txt", attachments.mapped("name")) + + def test_delivery_roulier_picking_batch_multiple_receiver(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.batch.carrier_id = self.carrier + self.picking_client_2.partner_id = self.other_receiver + with self.assertRaisesRegex( + UserError, "Multiple receiver addresses found for pickings:" + ): + self.batch.action_done() + + def test_delivery_roulier_picking_batch_no_receiver(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.batch.carrier_id = self.carrier + self.picking_client_2.partner_id = False + self.batch.action_done() + pkg = self.batch.picking_ids.package_ids + self.assertEqual(len(pkg), 1) + self.assertFalse(self.picking_client_1.carrier_tracking_ref) + self.assertFalse(self.picking_client_2.carrier_tracking_ref) + self.assertEqual(self.batch.carrier_tracking_ref, f"parcel_{pkg.name}_0") + + def test_delivery_roulier_picking_with_same_carrier_not_confirmed(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.picking_client_1.carrier_id = self.carrier + self.batch.carrier_id = self.carrier + self.batch.action_done() + pkg = self.batch.picking_ids.package_ids + self.assertEqual(len(pkg), 1) + self.assertFalse(self.picking_client_1.carrier_tracking_ref) + self.assertFalse(self.picking_client_2.carrier_tracking_ref) + self.assertEqual(self.batch.carrier_tracking_ref, f"parcel_{pkg.name}_0") + + def test_delivery_roulier_picking_with_other_carrier_not_confirmed(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + self.picking_client_1.carrier_id = self.env.ref( + "delivery.normal_delivery_carrier" + ) + self.batch.carrier_id = self.carrier + with self.assertRaisesRegex( + UserError, + f"Pickings {self.picking_client_1.name} already have a different carrier", + ): + self.batch.action_done() + + def test_delivery_roulier_picking_batch_not_roulier(self): + self.confirm_batch() + self.assertFalse(self.batch.picking_ids.package_ids) + with self.assertRaisesRegex(UserError, "Only Roulier carrier is supported"): + self.batch.carrier_id = self.env.ref("delivery.normal_delivery_carrier").id diff --git a/delivery_roulier_picking_batch/views/stock_picking_batch_views.xml b/delivery_roulier_picking_batch/views/stock_picking_batch_views.xml new file mode 100644 index 0000000000..72c69fcdb1 --- /dev/null +++ b/delivery_roulier_picking_batch/views/stock_picking_batch_views.xml @@ -0,0 +1,65 @@ + + + + + + stock.picking.batch + + + + + + + + + + + +
+
+
+
+
+ +
diff --git a/setup/delivery_roulier_picking_batch/odoo/addons/delivery_roulier_picking_batch b/setup/delivery_roulier_picking_batch/odoo/addons/delivery_roulier_picking_batch new file mode 120000 index 0000000000..b9977c6cc0 --- /dev/null +++ b/setup/delivery_roulier_picking_batch/odoo/addons/delivery_roulier_picking_batch @@ -0,0 +1 @@ +../../../../delivery_roulier_picking_batch \ No newline at end of file diff --git a/setup/delivery_roulier_picking_batch/setup.py b/setup/delivery_roulier_picking_batch/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/delivery_roulier_picking_batch/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)