diff --git a/shopfloor_mobile_packing/static/src/js/cluster-picking.js b/shopfloor_mobile_packing/static/src/js/cluster-picking.js
index cb9ed73a11..109d96b406 100644
--- a/shopfloor_mobile_packing/static/src/js/cluster-picking.js
+++ b/shopfloor_mobile_packing/static/src/js/cluster-picking.js
@@ -20,6 +20,64 @@ ClusterPickingBase.component.template = template.replace(
v-if="state_is('pack_picking_put_in_pack')"
:record="state.data"
/>
+
+
+
+
+
+
+
+
+
+
+ New pack
+
+
+
+
+ Process w/o pack
+
+
+
+
+
`
);
@@ -34,6 +92,50 @@ ClusterPickingBase.component.computed.searchbar_input_type = function () {
return "text";
};
+ClusterPickingBase.component.computed.existing_package_select_fields = function () {
+ return [
+ {path: "weight"},
+ {path: "move_line_count", label: "Line count"},
+ {path: "packaging.name"},
+ ];
+};
+
+ClusterPickingBase.component.methods.select_package_manual_select_opts = function () {
+ return {
+ multiple: true,
+ initValue: this.selected_line_ids(),
+ card_klass: "loud-labels",
+ list_item_component: "picking-select-package-content",
+ list_item_options: {actions: ["action_qty_edit"]},
+ };
+};
+
+ClusterPickingBase.component.methods.select_delivery_packaging_manual_select_options =
+ function () {
+ return {
+ showActions: false,
+ };
+ };
+
+ClusterPickingBase.component.methods.selected_line_ids = function () {
+ return this.selected_lines().map(_.property("id"));
+};
+
+ClusterPickingBase.component.methods.selectable_lines = function () {
+ const stored = this.state_get_data("select_package");
+ return _.result(stored, "selected_move_lines", []);
+};
+
+ClusterPickingBase.component.methods.selectable_line_ids = function () {
+ return this.selectable_lines().map(_.property("id"));
+};
+
+ClusterPickingBase.component.methods.selected_lines = function () {
+ return this.selectable_lines().filter(function (x) {
+ return x.qty_done > 0;
+ });
+};
+
// Replace the data method with our new method to add
// our new state
let component = ClusterPickingBase.component;
@@ -80,6 +182,160 @@ let data = function () {
this.wait_call(this.odoo.call(endpoint, endpoint_data));
},
};
+ result.states.select_package = {
+ // TODO: /set_line_qty is not handled yet
+ // because is not clear how to handle line selection
+ // and qty set.
+ // ATM given that manual-select uses v-list-item-group
+ // when you touch a line you select/unselect it
+ // which means we cannot rely on this to go to edit.
+ // If we need it, we have to change manual-select
+ // to use pure list + checkboxes.
+ display_info: {
+ title: "Select package",
+ scan_placeholder: "Scan existing package / package type",
+ },
+ events: {
+ qty_edit: "on_qty_edit",
+ select: "on_select",
+ back: "on_back",
+ },
+ on_scan: (scanned) => {
+ this.wait_call(
+ this.odoo.call("scan_package_action", {
+ picking_id: this.state.data.picking.id,
+ selected_line_ids: this.selectable_line_ids(),
+ barcode: scanned.text,
+ })
+ );
+ },
+ on_select: (selected) => {
+ return;
+ // TODO:
+ // if (!selected) {
+ // return;
+ // }
+ // const orig_selected = $instance.selected_line_ids();
+ // const selected_ids = selected.map(_.property("id"));
+ // const to_select = _.head(
+ // $instance.selectable_lines().filter(function (x) {
+ // return selected_ids.includes(x.id) && !orig_selected.includes(x.id);
+ // })
+ // );
+ // const to_unselect = _.head(
+ // $instance.selectable_lines().filter(function (x) {
+ // return !selected_ids.includes(x.id) && orig_selected.includes(x.id);
+ // })
+ // );
+ // let endpoint, move_line;
+ // if (to_unselect) {
+ // endpoint = "reset_line_qty";
+ // move_line = to_unselect;
+ // } else if (to_select) {
+ // endpoint = "set_line_qty";
+ // move_line = to_select;
+ // }
+ // $instance.wait_call(
+ // $instance.odoo.call(endpoint, {
+ // picking_id: $instance.state.data.picking.id,
+ // selected_line_ids: $instance.selectable_line_ids(),
+ // move_line_id: move_line.id,
+ // })
+ // );
+ },
+ on_qty_edit: (record) => {
+ return;
+ // TODO:
+ // $instance.state_set_data(
+ // {
+ // picking: $instance.state.data.picking,
+ // line: record,
+ // selected_line_ids: $instance.selectable_line_ids(),
+ // },
+ // "change_quantity"
+ // );
+ // $instance.state_to("change_quantity");
+ },
+ on_new_pack: () => {
+ /**
+ * Trigger the call to list delivery packaging types
+ * as user wants to put porducts in a new pack.
+ */
+ let endpoint, endpoint_data;
+ const data = this.state.data;
+ endpoint = "list_delivery_packaging";
+ endpoint_data = {
+ picking_batch_id: this.current_batch().id,
+ picking_id: data.picking.id,
+ selected_line_ids: this.selectable_line_ids(),
+ };
+ this.wait_call(this.odoo.call(endpoint, endpoint_data));
+ },
+ on_existing_pack: () => {
+ return;
+ // TODO:
+ // $instance.wait_call(
+ // $instance.odoo.call("list_dest_package", {
+ // picking_id: $instance.state.data.picking.id,
+ // selected_line_ids: $instance.selectable_line_ids(),
+ // })
+ // );
+ },
+ on_without_pack: () => {
+ return;
+ // TODO:
+ // $instance.wait_call(
+ // $instance.odoo.call("no_package", {
+ // picking_id: $instance.state.data.picking.id,
+ // selected_line_ids: $instance.selectable_line_ids(),
+ // })
+ // );
+ },
+ on_back: () => {
+ $instance.state_to("select_line");
+ $instance.reset_notification();
+ },
+ };
+ result.states.select_delivery_packaging = {
+ /**
+ * This will catch user events when selecting the delivery packaging type
+ * from:
+ * - the scanned barcode
+ * - the direct selection on screen
+ */
+ display_info: {
+ title: "Select delivery packaging or scan it",
+ scan_placeholder: "Scan package type",
+ },
+ events: {
+ select: "on_select",
+ back: "on_back",
+ },
+ on_select: (selected) => {
+ const picking = this.current_doc().record;
+ const data = this.state.data.picking;
+ this.wait_call(
+ this.odoo.call("put_in_pack", {
+ picking_batch_id: this.current_batch().id,
+ picking_id: data.id,
+ selected_line_ids: this.selected_line_ids(),
+ package_type_id: selected.id,
+ })
+ );
+ },
+ on_scan: (scanned) => {
+ const picking = this.current_doc().record;
+ const data = this.state.data;
+ this.wait_call(
+ this.odoo.call("scan_package_action", {
+ picking_id: data.id,
+ selected_line_ids: this.selected_line_ids(),
+ barcode: scanned.text,
+ })
+ );
+ },
+ };
+
return result;
};
diff --git a/shopfloor_packing/__manifest__.py b/shopfloor_packing/__manifest__.py
index 96342a8a66..32b1c8f1f4 100644
--- a/shopfloor_packing/__manifest__.py
+++ b/shopfloor_packing/__manifest__.py
@@ -12,6 +12,7 @@
"shopfloor",
"internal_stock_quant_package",
"delivery_package_type_number_parcels",
+ "stock_picking_delivery_package_type_domain",
],
"data": ["views/shopfloor_menu.xml", "views/stock_picking.xml"],
"installable": True,
diff --git a/shopfloor_packing/actions/data.py b/shopfloor_packing/actions/data.py
index e8d036e7c9..dc36ffabb7 100644
--- a/shopfloor_packing/actions/data.py
+++ b/shopfloor_packing/actions/data.py
@@ -12,3 +12,21 @@ def _package_parser(self):
res = super()._package_parser
res.append("is_internal")
return res
+
+ def _data_for_packing_info(self, picking):
+ """Return the packing information
+
+ Intended to be extended.
+ """
+ # TODO: This could be avoided if included in the picking parser.
+ return ""
+
+ def select_package(self, picking, lines):
+ return {
+ "selected_move_lines": self.move_lines(lines.sorted()),
+ "picking": self.picking(picking),
+ "packing_info": self._data_for_packing_info(picking),
+ # "no_package_enabled": not self.options.get("checkout__disable_no_package"),
+ # Used by inheriting module
+ "package_allowed": True,
+ }
diff --git a/shopfloor_packing/actions/schema.py b/shopfloor_packing/actions/schema.py
index a59a168516..74cc9642bc 100644
--- a/shopfloor_packing/actions/schema.py
+++ b/shopfloor_packing/actions/schema.py
@@ -12,3 +12,28 @@ def package(self, with_packaging=False):
schema = super().package(with_packaging=with_packaging)
schema["is_internal"] = {"required": False, "type": "boolean"}
return schema
+
+ def select_package(self) -> dict:
+ """
+ This will return the schema expected to display the action to select the
+ package to put in.
+ """
+ schema = {
+ "selected_move_lines": {
+ "type": "list",
+ "schema": self._schema_dict_of(self.move_line()),
+ },
+ "picking": self._schema_dict_of(self.picking()),
+ "packing_info": {"type": "string", "nullable": True},
+ "no_package_enabled": {
+ "type": "boolean",
+ "nullable": True,
+ "required": False,
+ },
+ "package_allowed": {
+ "type": "boolean",
+ "nullable": True,
+ "required": False,
+ },
+ }
+ return schema
diff --git a/shopfloor_packing/models/shopfloor_menu.py b/shopfloor_packing/models/shopfloor_menu.py
index 7786b41946..630dd15bcf 100644
--- a/shopfloor_packing/models/shopfloor_menu.py
+++ b/shopfloor_packing/models/shopfloor_menu.py
@@ -13,3 +13,11 @@ class ShopfloorMenu(models.Model):
help="If you tick this box, all the picked item will be put in pack"
" before the transfer.",
)
+
+ default_pack_pickings_action = fields.Selection(
+ [
+ ("nbr_packages", "Enter the number of packages"),
+ ("package_type", "Scan the package type"),
+ ],
+ default="nbr_packages",
+ )
diff --git a/shopfloor_packing/services/__init__.py b/shopfloor_packing/services/__init__.py
index b2faa17efc..ac4596b132 100644
--- a/shopfloor_packing/services/__init__.py
+++ b/shopfloor_packing/services/__init__.py
@@ -1 +1 @@
-from . import cluster_picking
+from . import cluster_picking, packaging
diff --git a/shopfloor_packing/services/cluster_picking.py b/shopfloor_packing/services/cluster_picking.py
index 61fd381dbb..0909fb2ba9 100644
--- a/shopfloor_packing/services/cluster_picking.py
+++ b/shopfloor_packing/services/cluster_picking.py
@@ -6,10 +6,118 @@
from odoo.addons.base_rest.components.service import to_int
from odoo.addons.component.core import Component
+from .packaging import PackagingAction
+
class ClusterPicking(Component):
+
_inherit = "shopfloor.cluster.picking"
+ def _get_available_delivery_packaging(self, picking):
+ model = self.env["stock.package.type"]
+ carrier = picking.ship_carrier_id or picking.carrier_id
+ wizard_obj = self.env["choose.delivery.package"]
+ delivery_type = (
+ carrier.delivery_type
+ if carrier.delivery_type not in ("fixed", False)
+ else "none"
+ )
+ wizard = wizard_obj.with_context(
+ current_package_carrier_type=delivery_type
+ ).new({"picking_id": picking.id})
+ if not carrier:
+ return model.browse()
+ return model.search(
+ wizard.package_type_domain,
+ order="number_of_parcels,name",
+ )
+
+ def list_delivery_packaging(self, picking_batch_id, picking_id, selected_line_ids):
+ """List available delivery packaging for given picking.
+
+ Transitions:
+ * select_delivery_packaging: list available delivery packaging, the
+ user has to choose one to create the new package
+ * select_package: when no delivery packaging is available
+ """
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_start(message=message)
+ selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ delivery_packaging = self._get_available_delivery_packaging(picking)
+ if not delivery_packaging:
+ return self._response_for_select_package(
+ picking,
+ selected_lines,
+ message=self.msg_store.no_delivery_packaging_available(),
+ )
+ response = self._check_allowed_qty_done(picking, selected_lines)
+ if response:
+ return response
+ return self._response_for_select_delivery_packaging(picking, delivery_packaging)
+
+ def scan_package_action(self, picking_id, selected_line_ids, barcode):
+ """Scan a package, a lot, a product or a package to handle a line
+
+ When a package is scanned (only delivery ones), if the package is known
+ as the destination package of one of the lines or is the source package
+ of a selected line, the package is set to be the destination package of
+ all the lines to pack.
+
+ When a product is scanned, it selects (set qty_done = reserved qty) or
+ deselects (set qty_done = 0) the move lines for this product. Only
+ products not tracked by lot can use this.
+
+ When a lot is scanned, it does the same as for the products but based
+ on the lot.
+
+ When a packaging type (one without related product) is scanned, a new
+ package is created and set as destination of the lines to pack.
+
+ Lines to pack are move lines in the list of ``selected_line_ids``
+ where ``qty_done`` > 0 and have not been packed yet
+ (``shopfloor_checkout_done is False``).
+
+ Transitions:
+ * select_package: when a product or lot is scanned to select/deselect,
+ the client app has to show the same screen with the updated selection
+ * select_line: when a package or packaging type is scanned, move lines
+ have been put in package and we can return back to this state to handle
+ the other lines
+ * summary: if there is no other lines, go to the summary screen to be able
+ to close the stock picking
+ """
+ packaging_action: PackagingAction = self._actions_for("packaging")
+ picking = self.env["stock.picking"].browse(picking_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_select_document(message=message)
+
+ selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
+ search_result = packaging_action._scan_package_find(picking, barcode)
+ message = packaging_action._check_scan_package_find(picking, search_result)
+ if message:
+ return self._response_for_select_package(
+ picking,
+ selected_lines,
+ message=message,
+ )
+ if search_result and search_result.type == "delivery_packaging":
+ package_type_id = search_result.record.id
+ else:
+ return self._response_for_select_package(
+ picking,
+ selected_lines,
+ message=self.msg_store.package_not_found_for_barcode(barcode),
+ )
+ # Call the specific put in pack with package type filled in
+ return self._put_in_pack(self, picking, package_type_id=package_type_id)
+
+ @property
+ def default_pick_pack_action(self):
+ return self.work.menu.default_pack_pickings_action
+
def _last_picked_line(self, picking):
# a complete override to add a condition on internal package
return fields.first(
@@ -110,8 +218,14 @@ def scan_packing_to_pack(self, picking_batch_id, picking_id, barcode):
batch,
)
+ def _get_move_lines_to_pack(self, picking):
+ return picking.move_line_ids.filtered(
+ lambda ml: ml.result_package_id.is_internal
+ ).sorted(key=lambda ml: ml.result_package_id.name)
+
def _prepare_pack_picking(self, batch, message=None):
picking = self._get_next_picking_to_pack(batch)
+ move_lines = self._get_move_lines_to_pack(picking)
if not picking:
return self._response_put_in_pack(
batch.id,
@@ -119,7 +233,13 @@ def _prepare_pack_picking(self, batch, message=None):
)
if picking.is_shopfloor_packing_pack_to_scan():
return self._response_pack_picking_scan_pack(picking, message=message)
- return self._response_pack_picking_put_in_pack(picking, message=message)
+ if self.default_pick_pack_action == "nbr_packages":
+ return self._response_pack_picking_put_in_pack(picking, message=message)
+ else:
+ return self._response_for_select_package(
+ picking, move_lines, message=message
+ )
+ # return self._response_pack_picking_put_in_pack(picking, message=message)
def prepare_unload(self, picking_batch_id):
# before initializing the unloading phase we put picking in pack if
@@ -131,30 +251,30 @@ def prepare_unload(self, picking_batch_id):
return super().prepare_unload(picking_batch_id)
return self._prepare_pack_picking(batch)
- def put_in_pack(self, picking_batch_id, picking_id, nbr_packages):
+ def put_in_pack(
+ self, picking_batch_id, picking_id, nbr_packages=None, package_type_id=None
+ ):
batch = self.env["stock.picking.batch"].browse(picking_batch_id)
if not batch.exists():
return self._response_batch_does_not_exist()
picking = batch.picking_ids.filtered(
lambda p, picking_id=picking_id: p.id == picking_id
)
- if not picking:
- return self._response_put_in_pack(
- picking_batch_id,
- message=self.msg_store.stock_picking_not_found(),
- )
- if not picking.is_shopfloor_packing_todo:
- return self._response_put_in_pack(
- picking_batch_id,
- message=self.msg_store.stock_picking_already_packed(picking),
- )
- if nbr_packages <= 0:
- return self._response_put_in_pack(
- picking_batch_id,
- message=self.msg_store.nbr_packages_must_be_greated_than_zero(),
- )
+
+ # Check if parameters are correct
+ packaging_action: PackagingAction = self._actions_for("packaging")
+ result = packaging_action._check_put_in_pack(
+ picking_batch_id,
+ picking,
+ self._response_put_in_pack,
+ nbr_packages=nbr_packages,
+ package_type_id=package_type_id,
+ )
+ if result:
+ return result
+
savepoint = self._actions_for("savepoint").new()
- pack = self._put_in_pack(picking, nbr_packages)
+ pack = self._put_in_pack(picking, nbr_packages, package_type_id)
picking._reset_packing_packs_scanned()
if not pack:
savepoint.rollback()
@@ -173,7 +293,7 @@ def _postprocess_put_in_pack(self, picking, pack):
such as printing.."""
return
- def _put_in_pack(self, picking, number_of_parcels):
+ def _put_in_pack(self, picking, number_of_parcels=None, package_type_id=None):
move_lines_to_pack = picking.move_line_ids.filtered(
lambda l: l.result_package_id and l.result_package_id.is_internal
)
@@ -185,15 +305,97 @@ def _put_in_pack(self, picking, number_of_parcels):
):
pack = self.env["stock.quant.package"].browse(pack.get("res_id"))
if isinstance(pack, self.env["stock.quant.package"].__class__):
- pack.number_of_parcels = number_of_parcels
+ # Enhance package details either with number of packages or package_type
+ if number_of_parcels:
+ pack.number_of_parcels = number_of_parcels
+ elif package_type_id:
+ pack.package_type_id = self.env["stock.package.type"].browse(
+ package_type_id
+ )
return pack
def _response_put_in_pack(self, picking_batch_id, message=None):
+ """
+ Fallback to prepare_unload
+ """
res = self.prepare_unload(picking_batch_id)
if message:
res["message"] = message
return res
+ def _data_for_packing_info(self, picking):
+ """Return the packing information
+
+ Intended to be extended.
+ """
+ # TODO: This could be avoided if included in the picking parser.
+ return ""
+
+ def _response_for_select_package(self, picking, lines, message=None):
+ return self._response(
+ next_state="select_package",
+ data=self.data.select_package(picking, lines),
+ message=message,
+ )
+
+ def _response_for_select_dest_package(self, picking, message=None):
+ packages = picking.mapped("move_line_ids.result_package_id").filtered(
+ "package_type_id"
+ )
+ if not packages:
+ # FIXME: do we want to move from 'select_dest_package' to
+ # 'select_package' state? Until now (before enforcing the use of
+ # delivery package) this part of code was never reached as we
+ # always had a package on the picking (source or result)
+ # Also the response validator did not support this state...
+ return self._response_for_select_package(
+ picking,
+ message=self.msg_store.no_valid_package_to_select(),
+ )
+ picking_data = self.data.picking(picking)
+ packages_data = self.data.packages(
+ packages.with_context(picking_id=picking.id).sorted(),
+ picking=picking,
+ with_packaging=True,
+ with_package_move_line_count=True,
+ )
+ return self._response(
+ next_state="select_dest_package",
+ data={
+ "picking": picking_data,
+ "packages": packages_data,
+ # "selected_move_lines": self._data_for_move_lines(move_lines.sorted()),
+ },
+ message=message,
+ )
+
+ def _data_for_delivery_packaging(self, packaging, **kw):
+ return self.data.delivery_packaging_list(packaging, **kw)
+
+ def _response_for_select_delivery_packaging(self, picking, packaging, message=None):
+ return self._response(
+ next_state="select_delivery_packaging",
+ data={
+ "picking": self.data.picking(picking),
+ "packaging": self._data_for_delivery_packaging(packaging),
+ },
+ message=message,
+ )
+
+ def _check_allowed_qty_done(self, picking, lines):
+ for line in lines:
+ # Do not allow to proceed if the qty_done of
+ # any of the selected lines
+ # is higher than the quantity to do.
+ if line.qty_done > line.reserved_uom_qty:
+ return self._response_for_select_package(
+ picking,
+ lines,
+ message=self.msg_store.selected_lines_qty_done_higher_than_allowed(
+ line
+ ),
+ )
+
class ShopfloorClusterPickingValidator(Component):
"""Validators for the Cluster Picking endpoints."""
@@ -208,7 +410,8 @@ def put_in_pack(self):
"type": "integer",
},
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
- "nbr_packages": {"coerce": to_int, "required": True, "type": "integer"},
+ "nbr_packages": {"coerce": to_int, "required": False, "type": "integer"},
+ "package_type_id": {"coerce": to_int, "required": False, "type": "integer"},
}
def scan_packing_to_pack(self):
@@ -222,16 +425,44 @@ def scan_packing_to_pack(self):
"barcode": {"required": True, "type": "string"},
}
+ def list_delivery_packaging(self) -> dict:
+ return {
+ "picking_batch_id": {
+ "coerce": to_int,
+ "required": True,
+ "type": "integer",
+ },
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "selected_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ }
+
+ def scan_package_action(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "selected_line_ids": {
+ "type": "list",
+ "required": True,
+ "schema": {"coerce": to_int, "required": True, "type": "integer"},
+ },
+ "barcode": {"required": True, "type": "string"},
+ }
+
class ShopfloorClusterPickingValidatorResponse(Component):
"""Validators for the Cluster Picking endpoints responses."""
_inherit = "shopfloor.cluster_picking.validator.response"
- def _states(self):
+ def _states(self) -> dict:
states = super()._states()
states["pack_picking_put_in_pack"] = self.schemas_detail.pack_picking_detail()
states["pack_picking_scan_pack"] = self.schemas_detail.pack_picking_detail()
+ states["select_package"] = self.schemas.select_package()
+ states["select_delivery_packaging"] = self._schema_select_delivery_packaging
return states
@property
@@ -239,10 +470,16 @@ def _schema_pack_picking(self):
schema = self.schemas_detail.pack_picking_detail()
return {"type": "dict", "nullable": True, "schema": schema}
+ @property
+ def _schema_select_package(self):
+ schema = self.schemas.select_package()
+ return {"type": "dict", "nullable": True, "schema": schema}
+
def prepare_unload(self):
res = super().prepare_unload()
res["data"]["schema"]["pack_picking_put_in_pack"] = self._schema_pack_picking
res["data"]["schema"]["pack_picking_scan_pack"] = self._schema_pack_picking
+ res["data"]["schema"]["select_package"] = self._schema_select_package
return res
def put_in_pack(self):
@@ -252,6 +489,14 @@ def confirm_start(self):
res = super().confirm_start()
res["data"]["schema"]["pack_picking_put_in_pack"] = self._schema_pack_picking
res["data"]["schema"]["pack_picking_scan_pack"] = self._schema_pack_picking
+ res["data"]["schema"]["select_package"] = self._schema_select_package
+ return res
+
+ def select_package(self):
+ res = self._response_schema(
+ next_states={"select_delivery_packaging", "select_package"}
+ )
+ res["data"]["schema"]["select_package"] = self._schema_select_package
return res
def scan_destination_pack(self):
@@ -267,5 +512,23 @@ def scan_packing_to_pack(self):
"unload_single",
"pack_picking_put_in_pack",
"pack_picking_scan_pack",
+ "select_package",
}
)
+
+ def list_delivery_packaging(self):
+ return self._response_schema(
+ next_states={"select_delivery_packaging", "select_package"}
+ )
+
+ @property
+ def _schema_select_delivery_packaging(self):
+ return {
+ "picking": {"type": "dict", "schema": self.schemas.picking()},
+ "packaging": self.schemas._schema_list_of(
+ self.schemas.delivery_packaging()
+ ),
+ }
+
+ def scan_package_action(self):
+ return self.select_package()
diff --git a/shopfloor_packing/services/packaging.py b/shopfloor_packing/services/packaging.py
new file mode 100644
index 0000000000..1094051575
--- /dev/null
+++ b/shopfloor_packing/services/packaging.py
@@ -0,0 +1,75 @@
+# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com)
+# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM)
+# Copyright 2024 ACSONE SA/NV (https://www.acsone.eu)
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+
+
+from odoo.addons.component.core import Component
+from odoo.addons.stock.models.stock_picking import Picking
+
+
+class PackagingAction(Component):
+
+ _inherit = "shopfloor.packaging.action"
+
+ def _check_put_in_pack(
+ self,
+ record,
+ picking: Picking,
+ response_error_func: callable,
+ nbr_packages: int = None,
+ package_type_id: int = None,
+ ):
+ """
+ This will check if parameters are correct and return a response with
+ the appropriate message.
+ """
+ if not picking:
+ return response_error_func(
+ record,
+ message=self.msg_store.stock_picking_not_found(),
+ )
+ if not picking.is_shopfloor_packing_todo:
+ return response_error_func(
+ record,
+ message=self.msg_store.stock_picking_already_packed(picking),
+ )
+ if isinstance(nbr_packages, int) and nbr_packages <= 0:
+ return response_error_func(
+ record,
+ message=self.msg_store.nbr_packages_must_be_greated_than_zero(),
+ )
+ # Check if package type exists
+ if package_type_id and not nbr_packages:
+ package_type = (
+ self.env["stock.package.type"].browse(package_type_id).exists()
+ )
+ if not package_type:
+ return response_error_func(
+ record,
+ message=self.msg_store.record_not_found(),
+ )
+ return False
+
+ def _scan_package_find(self, picking, barcode, search_types=None):
+ search = self._actions_for("search")
+ search_types = (
+ "package",
+ "product",
+ "packaging",
+ "lot",
+ "serial",
+ "delivery_packaging",
+ )
+ return search.find(
+ barcode,
+ types=search_types,
+ handler_kw=dict(
+ lot=dict(products=picking.move_ids.product_id),
+ serial=dict(products=picking.move_ids.product_id),
+ ),
+ )
+
+ def _check_scan_package_find(self, picking, search_result):
+ # Used by inheriting modules
+ return False
diff --git a/shopfloor_packing/tests/__init__.py b/shopfloor_packing/tests/__init__.py
index e84b11d98c..b3db59427f 100644
--- a/shopfloor_packing/tests/__init__.py
+++ b/shopfloor_packing/tests/__init__.py
@@ -1,2 +1,5 @@
-from . import test_cluster_picking_pack_picking
-from . import test_cluster_picking_unload
+from . import (
+ test_cluster_picking_pack_picking,
+ test_cluster_picking_pick_pack,
+ test_cluster_picking_unload,
+)
diff --git a/shopfloor_packing/tests/common.py b/shopfloor_packing/tests/common.py
new file mode 100644
index 0000000000..0655d67022
--- /dev/null
+++ b/shopfloor_packing/tests/common.py
@@ -0,0 +1,16 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo.addons.shopfloor.tests.test_cluster_picking_unload import (
+ ClusterPickingUnloadingCommonCase,
+)
+
+
+# pylint: disable=missing-return
+class ClusterPickingUnloadPackingCommonCase(ClusterPickingUnloadingCommonCase):
+ @classmethod
+ def setUpClassBaseData(cls, *args, **kwargs):
+ super().setUpClassBaseData(*args, **kwargs)
+ cls.bin1.write({"name": "bin1", "is_internal": True})
+ cls.bin2.write({"name": "bin2", "is_internal": True})
+ cls.menu.sudo().pack_pickings = True
diff --git a/shopfloor_packing/tests/test_cluster_picking_pack_picking.py b/shopfloor_packing/tests/test_cluster_picking_pack_picking.py
index 22a44f761b..1be91709e0 100644
--- a/shopfloor_packing/tests/test_cluster_picking_pack_picking.py
+++ b/shopfloor_packing/tests/test_cluster_picking_pack_picking.py
@@ -1,19 +1,6 @@
# Copyright 2021 ACSONE SA/NV
-# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-
-from odoo.addons.shopfloor.tests.test_cluster_picking_unload import (
- ClusterPickingUnloadingCommonCase,
-)
-
-
-# pylint: disable=missing-return
-class ClusterPickingUnloadPackingCommonCase(ClusterPickingUnloadingCommonCase):
- @classmethod
- def setUpClassBaseData(cls, *args, **kwargs):
- super().setUpClassBaseData(*args, **kwargs)
- cls.bin1.write({"name": "bin1", "is_internal": True})
- cls.bin2.write({"name": "bin2", "is_internal": True})
- cls.menu.sudo().pack_pickings = True
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from .common import ClusterPickingUnloadPackingCommonCase
class TestClusterPickingPrepareUnload(ClusterPickingUnloadPackingCommonCase):
diff --git a/shopfloor_packing/tests/test_cluster_picking_pick_pack.py b/shopfloor_packing/tests/test_cluster_picking_pick_pack.py
new file mode 100644
index 0000000000..0eb3ee893f
--- /dev/null
+++ b/shopfloor_packing/tests/test_cluster_picking_pick_pack.py
@@ -0,0 +1,111 @@
+# Copyright 2024 ACSONE SA/NV
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from .common import ClusterPickingUnloadPackingCommonCase
+
+
+class TestClusterPickingPrepareUnload(ClusterPickingUnloadPackingCommonCase):
+ def test_prepare_unload_all_same_dest_with_dest_package(self):
+ """
+ Activate the behavior that allows to pack at the pick step (cluster)
+ Activate the behavior that change the default action -> Scan the package type
+ At the unload step, ask to select a delivery package (from types)
+ """
+ self.menu.sudo().write(
+ {
+ "pick_pack_same_time": True,
+ "default_pack_pickings_action": "package_type",
+ }
+ )
+
+ move_lines = self.move_lines
+ self._set_dest_package_and_done(move_lines[:1], self.bin2)
+ self._set_dest_package_and_done(move_lines[1:], self.bin1)
+ move_lines.write({"location_dest_id": self.packing_location.id})
+ response = self.service.dispatch(
+ "prepare_unload", params={"picking_batch_id": self.batch.id}
+ )
+ location = self.packing_location
+ # The first bin to process is bin1 we should therefore scan the bin 1
+ # to pack and put in pack
+ picking = move_lines[-1].picking_id
+ data = self.data_detail.pack_picking_detail(picking)
+ self.assert_response(
+ response,
+ next_state="pack_picking_scan_pack",
+ data=data,
+ )
+ lines = picking.move_line_ids.filtered(
+ lambda ml: ml.result_package_id.is_internal
+ ).sorted(key=lambda ml: ml.result_package_id.name)
+ # we scan the pack
+ response = self.service.dispatch(
+ "scan_packing_to_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "picking_id": picking.id,
+ "barcode": self.bin1.name,
+ },
+ )
+
+ data = self.data.select_package(picking, lines)
+ self.assert_response(
+ response,
+ next_state="select_package",
+ data=data,
+ )
+ # we process to the put in pack
+ response = self.service.dispatch(
+ "put_in_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "picking_id": picking.id,
+ "nbr_packages": 4,
+ },
+ )
+ message = self.service.msg_store.stock_picking_packed_successfully(picking)
+ result_package = picking.move_line_ids.mapped("result_package_id")
+ self.assertEqual(len(result_package), 1)
+ self.assertEqual(result_package[0].number_of_parcels, 4)
+
+ picking = move_lines[0].picking_id
+ data = self.data_detail.pack_picking_detail(picking)
+ # message = self.service.msg_store.stock_picking_packed_successfully(picking)
+ self.assert_response(
+ response, next_state="pack_picking_scan_pack", data=data, message=message
+ )
+ lines = picking.move_line_ids.filtered(
+ lambda ml: ml.result_package_id.is_internal
+ ).sorted(key=lambda ml: ml.result_package_id.name)
+ # we scan the pack
+ response = self.service.dispatch(
+ "scan_packing_to_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "picking_id": picking.id,
+ "barcode": self.bin2.name,
+ },
+ )
+ data = self.data.select_package(picking, lines)
+ self.assert_response(
+ response,
+ next_state="select_package",
+ data=data,
+ )
+ # we process to the put in pack
+ response = self.service.dispatch(
+ "put_in_pack",
+ params={
+ "picking_batch_id": self.batch.id,
+ "picking_id": picking.id,
+ "nbr_packages": 2,
+ },
+ )
+ data = self._data_for_batch(self.batch, location)
+ message = self.service.msg_store.stock_picking_packed_successfully(picking)
+ self.assert_response(
+ response, next_state="unload_all", data=data, message=message
+ )
+
+ result_package = picking.move_line_ids.mapped("result_package_id")
+ self.assertEqual(len(result_package), 1)
+ self.assertEqual(result_package[0].number_of_parcels, 2)
diff --git a/shopfloor_packing/views/shopfloor_menu.xml b/shopfloor_packing/views/shopfloor_menu.xml
index d24718b4c0..d251ba72d1 100644
--- a/shopfloor_packing/views/shopfloor_menu.xml
+++ b/shopfloor_packing/views/shopfloor_menu.xml
@@ -14,6 +14,7 @@
attrs="{'invisible': [('scenario', '!=', 'cluster_picking')]}"
>
+
diff --git a/test-requirements.txt b/test-requirements.txt
index 0d19f48858..5c53a5e2f5 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -3,3 +3,7 @@ odoo_test_helper
# stock-logistics-tracking
odoo-addon-internal-stock-quant-package @ git+https://github.com/OCA/stock-logistics-tracking.git@refs/pull/34/head#subdirectory=setup/internal_stock_quant_package
+
+odoo-addon-stock-picking-delivery-package-type-domain @ git+https://github.com/OCA/delivery-carrier.git@refs/pull/846/head#subdirectory=setup/stock_picking_delivery_package_type_domain
+
+odoo-addon-shopfloor @ git+https://github.com/OCA/wms.git@refs/pull/942/head#subdirectory=setup/shopfloor