Rework workflows using a small state machine

The documentation of the state machine is in
VerticalLiftOperationBase._transitions.
This commit is contained in:
Guewen Baconnier
2020-06-29 14:36:13 +02:00
committed by Hai Lang
parent d1b7a6f47c
commit 7d78e8b540
14 changed files with 623 additions and 538 deletions

View File

@@ -38,7 +38,7 @@ class VerticalLiftCommand(models.Model):
if key:
return key[0]
else:
return ''
return ""
@api.model_create_multi
def create(self, vals_list):

View File

@@ -1,10 +1,15 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, fields, models
import logging
from collections import namedtuple
from odoo import api, fields, models
from odoo.addons.base_sparse_field.models.fields import Serialized
_logger = logging.getLogger(__name__)
class VerticalLiftOperationBase(models.AbstractModel):
"""Base model for shuttle operations (pick, put, inventory)"""
@@ -26,7 +31,18 @@ class VerticalLiftOperationBase(models.AbstractModel):
string="Number of Operations in all shuttles",
)
mode = fields.Selection(related="shuttle_id.mode", readonly=True)
operation_descr = fields.Char(string="Operation", default="...", readonly=True)
state = fields.Selection(
selection=lambda self: self._selection_states(),
default=lambda self: self._initial_state,
)
_initial_state = None # to define in sub-classes
# if there is an action and it's returning True, the transition is done,
# otherwise not
Transition = namedtuple("Transition", "current_state next_state action direct_eval")
# default values to None
Transition.__new__.__defaults__ = (None,) * len(Transition._fields)
_sql_constraints = [
(
@@ -36,6 +52,128 @@ class VerticalLiftOperationBase(models.AbstractModel):
)
]
def _selection_states(self):
return []
def _transitions(self):
"""Define the transitions between the states
To set in sub-classes.
It is a tuple of a ``Transition`` instances, evaluated in order.
A transition has a source step, a destination step, a function and a
flag ``direct_eval``.
When the function returns True, the transition is applied, otherwise,
the next transition matching the current step is evaluated.
When a transition has no function, it is always applied.
The flag ``direct_eval`` indicates that the workflow should directly
evaluates again the transitions to reach the next step. It allows to
use "virtual" steps that will never be kept for users but be used as
router.
The initial state must be defined in the attribute ``_initial_state``.
The transition from a step to another are triggered by a call to
``next_step()``. This method is called in several places:
* ``reset_steps()`` (called when the screen opens)
* ``button_save()``, generally used to post the move
* ``button_release()``, generally used to go to the next line
* ``on_barcode_scanned()``, the calls to ``next_step()`` are to
implement in sub-classed if the scanned barcode leads to the next
step
Example of workflow described below:
::
_initial_state = "noop"
def _selection_states(self):
return [
("noop", "No operations"),
("scan_destination", "Scan New Destination Location"),
("save", "Put goods in tray and save"),
("release", "Release"),
]
def _transitions(self):
return (
self.Transition(
"noop",
"scan_destination",
lambda self: self.select_next_move_line()
),
self.Transition("scan_destination", "save"),
self.Transition("save", "release"),
self.Transition(
"release",
"scan_destination",
lambda self: self.select_next_move_line()
),
self.Transition("release", "noop"),
)
When we arrive on the screen, the ``on_screen_open`` methods resets the
steps (``reset_steps()``). It ensures the current step is ``noop`` and
directly tries to reach the next step (call to ``next_step()``).
It tries to go from ``noop`` to ``scan_destination``, calling
``self.select_next_move_line()``. If the method finds a line, it
returns True and the transition is applied, otherwise, the step stays
``noop``.
The transitions from ``scan_destination`` and ``save`` and from
``save`` and ``release`` are always applied when ``next_step()`` is
called (``scan_destination`` → ``save`` from ``on_barcode_scanned``
when a destination was found, ``save`` → ``release`` from the save
button).
When ``button_release()`` is called, it calls ``next_step()`` which
first evaluates ``self.select_next_move_line()``: if a move line remains, it
goes to ``scan_destination``, otherwise to ``noop``.
"""
return ()
def step(self):
return self.state
def next_step(self, direct_eval=False):
current_state = self.state
for transition in self._transitions():
if direct_eval and not transition.direct_eval:
continue
if transition.current_state != current_state:
continue
if not transition.action or transition.action(self):
_logger.debug(
"Transition %s%s",
transition.current_state,
transition.next_state,
)
self.state = transition.next_state
break
# reevaluate the transitions if we have a new state with direct_eval transitions
if self.state != current_state and any(
transition.direct_eval
for transition in self._transitions()
if transition.current_state == self.state
):
self.next_step(direct_eval=True)
def reset_steps(self):
if not self._initial_state:
raise NotImplementedError("_initial_state must be defined")
self.state = self._initial_state
self.next_step()
def on_barcode_scanned(self, barcode):
self.ensure_one()
# to implement in sub-classes
def on_screen_open(self):
"""Called when the screen is opened"""
self.reset_steps()
def onchange(self, values, field_name, field_onchange):
if field_name == "_barcode_scanned":
# _barcode_scanner is implemented (in the barcodes module) as an
@@ -43,7 +181,7 @@ class VerticalLiftOperationBase(models.AbstractModel):
# normal button and actually have side effect in the database
# (update line, go to the next step, ...). This override shorts the
# onchange call and calls the scanner method as a normal method.
self.on_barcode_scanned(values['_barcode_scanned'])
self.on_barcode_scanned(values["_barcode_scanned"])
# We can't know which fields on_barcode_scanned changed, refresh
# everything.
return {"value": self.read()[0]}
@@ -60,16 +198,6 @@ class VerticalLiftOperationBase(models.AbstractModel):
for record in self:
record.number_of_ops_all = 0
def on_barcode_scanned(self, barcode):
self.ensure_one()
# to implement in sub-classes
def on_screen_open(self):
"""Called when the screen is open
To implement in sub-classes.
"""
def action_open_screen(self):
return self.shuttle_id.action_open_screen()
@@ -79,13 +207,26 @@ class VerticalLiftOperationBase(models.AbstractModel):
def action_manual_barcode(self):
return self.shuttle_id.action_manual_barcode()
def button_save(self):
"""Process the action (pick, put, ...)"""
def process_current(self):
"""Process the action (pick, put, ...)
To implement in sub-classes
"""
raise NotImplementedError
def button_save(self):
"""Confirm the operation (set move to done, ...)"""
self.ensure_one()
if not self.step() == "save":
return
self.next_step()
def button_release(self):
"""Release the operation, go to the next"""
raise NotImplementedError
self.ensure_one()
if not self.step() == "release":
return
self.next_step()
def _render_product_packagings(self, product):
values = {
@@ -254,32 +395,20 @@ class VerticalLiftOperationTransfer(models.AbstractModel):
self._domain_move_lines_to_do_all()
)
def on_screen_open(self):
"""Called when the screen is open"""
def button_release(self):
"""Release the operation, go to the next"""
self.select_next_move_line()
if not self.current_move_line_id:
# sorry not sorry
return {
"effect": {
"fadeout": "slow",
"message": _("Congrats, you cleared the queue!"),
"img_url": "/web/static/src/img/smile.svg",
"type": "rainbow_man",
}
}
def process_current(self):
raise NotImplementedError
def button_save(self):
if not (self and self.current_move_line_id):
return
self.ensure_one()
self.process_current()
self.operation_descr = _("Release")
line = self.current_move_line_id
if line.state in ("assigned", "partially_available"):
line.qty_done = line.product_qty
line.move_id._action_done()
return True
def fetch_tray(self):
raise NotImplementedError
def reset_steps(self):
self.clear_current_move_line()
super().reset_steps()
def clear_current_move_line(self):
self.current_move_line_id = False
return True

View File

@@ -14,6 +14,68 @@ class VerticalLiftOperationInventory(models.Model):
_inherit = "vertical.lift.operation.base"
_description = "Vertical Lift Operation Inventory"
_initial_state = "noop"
def _selection_states(self):
return [
("noop", "No inventory in progress"),
("quantity", "Inventory, please enter the amount"),
("confirm_wrong_quantity", "The quantity does not match, are you sure?"),
# save is never visible, but save and go to the next or noop directly
("save", "Save"),
# no need for release and save button here?
# ("release", "Release"),
]
def _transitions(self):
return (
self.Transition(
"noop",
"quantity",
# transition only if inventory lines are found
lambda self: self.select_next_inventory_line(),
),
self.Transition(
"quantity", "save", lambda self: self._has_identical_quantity(),
),
self.Transition(
"quantity",
"confirm_wrong_quantity",
lambda self: self._start_confirm_wrong_quantity(),
),
self.Transition(
"confirm_wrong_quantity",
"save",
lambda self: self.quantity_input == self.last_quantity_input,
),
# if the confirmation of the quantity is different, cycle back to
# the 'quantity' step
self.Transition(
"confirm_wrong_quantity",
"quantity",
lambda self: self._go_back_to_quantity_input(),
),
# go to quantity if we have lines in queue, otherwise, go to noop
self.Transition(
"save",
"quantity",
lambda self: self.process_current()
and self.select_next_inventory_line(),
# when we reach 'save', this transition is directly
# evaluated
direct_eval=True,
),
self.Transition(
"save",
"noop",
lambda self: self.process_current()
and self.clear_current_inventory_line(),
# when we reach 'save', this transition is directly
# evaluated
direct_eval=True,
),
)
current_inventory_line_id = fields.Many2one(
comodel_name="stock.inventory.line", readonly=True
)
@@ -22,14 +84,6 @@ class VerticalLiftOperationInventory(models.Model):
# if the quantity is wrong, user has to write 2 times
# the same quantity to really confirm it's correct
last_quantity_input = fields.Float()
state = fields.Selection(
selection=[
("quantity", "Inventory, please enter the amount"),
("confirm_wrong_quantity", "The quantity does not match, are you sure?"),
("save", "Save"),
],
default="quantity",
)
tray_location_id = fields.Many2one(
comodel_name="stock.location",
@@ -137,54 +191,9 @@ class VerticalLiftOperationInventory(models.Model):
("vertical_lift_done", "=", False),
]
def on_screen_open(self):
"""Called when the screen is open"""
self.select_next_inventory_line()
def reset(self):
self.write(
{"quantity_input": 0.0, "last_quantity_input": 0.0, "state": "quantity"}
)
self.update_step_description()
def step(self):
return self.state
def step_to(self, state):
self.state = state
self.update_step_description()
def step_description(self):
state_field = self._fields["state"]
return state_field.convert_to_export(self.state, self)
def update_step_description(self):
if self.current_inventory_line_id:
descr = self.step_description()
else:
descr = _("No operations")
self.operation_descr = descr
def button_save(self):
if not self.current_inventory_line_id:
return
self.ensure_one()
self.process_current()
if self.step() == "save":
self.select_next_inventory_line()
if not self.current_inventory_line_id:
# sorry not sorry
return {
"effect": {
"fadeout": "slow",
"message": _("Congrats, you cleared the queue!"),
"img_url": "/web/static/src/img/smile.svg",
"type": "rainbow_man",
}
}
def button_release(self):
raise NotImplementedError
def reset_steps(self):
self.clear_current_inventory_line()
super().reset_steps()
def _has_identical_quantity(self):
line = self.current_inventory_line_id
@@ -197,35 +206,25 @@ class VerticalLiftOperationInventory(models.Model):
== 0
)
def _process_quantity(self):
if self.step() == "quantity":
if self._has_identical_quantity():
self.step_to("save")
return True
else:
def _start_confirm_wrong_quantity(self):
self.last_quantity_input = self.quantity_input
self.quantity_input = 0.0
self.step_to("confirm_wrong_quantity")
return False
if self.step() == "confirm_wrong_quantity":
if self.quantity_input == self.last_quantity_input:
# confirms the previous input
self.step_to("save")
return True
else:
# cycle back to the first quantity check
self.step_to("quantity")
return self._process_quantity()
def process_current(self):
line = self.current_inventory_line_id
if self._process_quantity() and not line.vertical_lift_done:
line.vertical_lift_done = True
if self.quantity_input != line.product_qty:
line.product_qty = self.quantity_input
inventory = line.inventory_id
if all(line.vertical_lift_done for line in inventory.line_ids):
inventory.action_validate()
def _go_back_to_quantity_input(self):
self.last_quantity_input = self.quantity_input
self.quantity_input = 0.0
return True
def clear_current_inventory_line(self):
self.write(
{
"quantity_input": 0.0,
"last_quantity_input": 0.0,
"current_inventory_line_id": False,
}
)
return True
def fetch_tray(self):
location = self.current_inventory_line_id.location_id
@@ -233,16 +232,44 @@ class VerticalLiftOperationInventory(models.Model):
def select_next_inventory_line(self):
self.ensure_one()
previous_line = self.current_inventory_line_id
next_line = self.env["stock.inventory.line"].search(
self._domain_inventory_lines_to_do(),
limit=1,
order="vertical_lift_tray_id, location_id, id",
)
previous_line = self.current_inventory_line_id
self.current_inventory_line_id = next_line
self.reset()
if (
next_line
and previous_line.vertical_lift_tray_id != next_line.vertical_lift_tray_id
):
self.fetch_tray()
return bool(next_line)
def process_current(self):
line = self.current_inventory_line_id
if not line.vertical_lift_done:
line.vertical_lift_done = True
if self.quantity_input != line.product_qty:
line.product_qty = self.quantity_input
inventory = line.inventory_id
if all(line.vertical_lift_done for line in inventory.line_ids):
inventory.action_validate()
self.quantity_input = self.last_quantity_input = 0.0
return True
def button_save(self):
self.ensure_one()
if not self.step() in ("quantity", "confirm_wrong_quantity"):
return
self.next_step()
if self.step() == "noop":
# sorry not sorry
return {
"effect": {
"fadeout": "slow",
"message": _("Congrats, you cleared the queue!"),
"img_url": "/web/static/src/img/smile.svg",
"type": "rainbow_man",
}
}

View File

@@ -9,14 +9,39 @@ class VerticalLiftOperationPick(models.Model):
_inherit = "vertical.lift.operation.transfer"
_description = "Vertical Lift Operation Pick"
_initial_state = "noop"
def _selection_states(self):
return [
("noop", "No operations"),
("scan_destination", "Scan New Destination Location"),
("save", "Pick goods and save"),
("release", "Release"),
]
def _transitions(self):
return (
self.Transition(
"noop", "scan_destination", lambda self: self.select_next_move_line()
),
self.Transition("scan_destination", "save"),
self.Transition("save", "release", lambda self: self.process_current()),
# go to scan_destination if we have lines in queue, otherwise, go to noop
self.Transition(
"release", "scan_destination", lambda self: self.select_next_move_line()
),
self.Transition("release", "noop"),
)
def on_barcode_scanned(self, barcode):
self.ensure_one()
if not self.current_move_line_id or self.current_move_line_id.state == "done":
return
if self.step() == "scan_destination":
location = self.env["stock.location"].search([("barcode", "=", barcode)])
if location:
self.location_dest_id = location
self.operation_descr = _("Save")
self.next_step()
else:
self.env.user.notify_warning(
_("No location found for barcode {}").format(barcode)
@@ -42,27 +67,27 @@ class VerticalLiftOperationPick(models.Model):
def fetch_tray(self):
self.current_move_line_id.fetch_vertical_lift_tray_source()
def process_current(self):
line = self.current_move_line_id
if line.state in ("assigned", "partially_available"):
line.qty_done = line.product_qty
line.move_id._action_done()
def on_screen_open(self):
"""Called when the screen is open"""
self.select_next_move_line()
def select_next_move_line(self):
self.ensure_one()
next_move_line = self.env["stock.move.line"].search(
self._domain_move_lines_to_do(), limit=1
)
self.current_move_line_id = next_move_line
# TODO use a state machine to define next steps and
# description?
descr = (
_("Scan New Destination Location") if next_move_line else _("No operations")
)
self.operation_descr = descr
if next_move_line:
self.fetch_tray()
return True
return False
def button_release(self):
"""Release the operation, go to the next"""
super().button_release()
if self.step() == "noop":
# sorry not sorry
return {
"effect": {
"fadeout": "slow",
"message": _("Congrats, you cleared the queue!"),
"img_url": "/web/static/src/img/smile.svg",
"type": "rainbow_man",
}
}

View File

@@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, fields, models
from odoo.osv import expression
from odoo.osv.expression import AND
class VerticalLiftOperationPut(models.Model):
@@ -10,210 +10,153 @@ class VerticalLiftOperationPut(models.Model):
_inherit = "vertical.lift.operation.transfer"
_description = "Vertical Lift Operation Put"
operation_line_ids = fields.One2many(
comodel_name="vertical.lift.operation.put.line",
inverse_name="operation_id",
readonly=True,
)
current_operation_line_id = fields.Many2one(
comodel_name="vertical.lift.operation.put.line", readonly=True
)
current_move_line_id = fields.Many2one(
related="current_operation_line_id.move_line_id", readonly=True
)
# TODO think about moving the "steps" to the base model,
# integrate 'save' and 'release' in 'next_step()', use states
# in 'pick' as well
state = fields.Selection(
selection=[
("scan_product", "Scan Product"),
("scan_tray_type", "Scan Tray Type"),
("save", "Save"),
("release", "Release"),
],
default="scan_product",
)
_initial_state = "scan_source"
next_operation = object()
def _selection_states(self):
return [
("scan_source", "Scan a package, product or lot to put-away"),
("scan_tray_type", "Scan Tray Type"),
("save", "Put goods in tray and save"),
("release", "Release"),
]
def _transitions(self):
return {
"scan_product": "scan_tray_type",
"scan_tray_type": "save",
"save": "release",
"release": self.next_operation,
}
# The steps cannot be in 'vertical.lift.operation.put.line' because the
# state has to be modified by on_barcode_scanned. As this method is an
# onchange underneath, it has to be on the same model.
def step(self):
return self.state
def next_step(self):
next_state = self._transitions().get(self.state)
if next_state is not self.next_operation:
self.state = next_state
self.update_step_description()
def step_description(self):
state_field = self._fields["state"]
return state_field.convert_to_export(self.state, self)
def reset_steps(self):
self.state = "scan_product"
self.update_step_description()
def count_move_lines_to_do(self):
"""Count move lines to process in current shuttle"""
self.ensure_one()
return self.env["vertical.lift.operation.put.line"].search_count(
[("operation_id", "=", self.id)]
return (
self.Transition(
"scan_source",
"scan_tray_type",
# transition only if a move line has been selected
# (by on_barcode_scanner)
lambda self: self.current_move_line_id,
),
self.Transition("scan_tray_type", "save"),
self.Transition("save", "release", lambda self: self.process_current()),
self.Transition(
"release", "scan_source", lambda self: self.clear_current_move_line()
),
)
def count_move_lines_to_do_all(self):
"""Count move lines to process in all shuttles"""
self.ensure_one()
return self.env["vertical.lift.operation.put.line"].search_count([])
def _domain_move_lines_to_do(self):
domain = [
("state", "in", ("assigned", "partially_available")),
("location_dest_id", "child_of", self.location_id.id),
]
return domain
def _domain_move_lines_to_do_all(self):
shuttle_locations = self.env["stock.location"].search(
[("vertical_lift_kind", "=", "view")]
)
domain = [
("state", "in", ("assigned", "partially_available")),
("location_dest_id", "child_of", shuttle_locations.ids),
]
return domain
def on_barcode_scanned(self, barcode):
self.ensure_one()
operation_line = self.current_operation_line_id
if operation_line:
if self.step() == "scan_product":
if self._check_product(barcode):
self.next_step()
if self.step() == "scan_tray_type":
if self._check_tray_type(barcode):
if self.step() == "scan_source":
line = self._find_move_line(barcode)
if line:
self.current_move_line_id = line
self.next_step()
else:
self.env.user.notify_warning(
_("No move line found for barcode {}").format(barcode)
)
def _check_product(self, barcode):
return barcode == self.current_move_line_id.product_id.barcode
# TODO if the move line already has a storage type, assign directly a
# destination and save
elif self.step() == "scan_tray_type":
tray_type = self._find_tray_type(barcode)
if tray_type:
if self._assign_available_cell(tray_type):
self.fetch_tray()
self.next_step()
else:
self.env.user.notify_warning(
_("No free space for this tray type in this shuttle.")
)
else:
self.env.user.notify_warning(
_("No tray type found for barcode {}").format(barcode)
)
def _find_tray_type(self, barcode):
return self.env["stock.location.tray.type"].search(
[("code", "=", barcode)], limit=1
)
def _find_move_line(self, barcode):
package = self.env["stock.quant.package"].search([("name", "=", barcode)])
if package:
return self._find_move_line_for_package(package)
lot = self.env["stock.production.lot"].search([("name", "=", barcode)])
if lot:
return self._find_move_line_for_lot(package)
product = self.env["product.product"].search([("barcode", "=", barcode)])
if not product:
packaging = self.env["product.packaging"].search(
[("product_id", "!=", False), ("barcode", "=", barcode)]
)
product = packaging.product_id
if product:
return self._find_move_line_for_product(product)
def _find_move_line_for_package(self, package):
domain = AND(
[self._domain_move_lines_to_do_all(), [("package_id", "=", package.id)]]
)
return self.env["stock.move.line"].search(domain, limit=1)
def _find_move_line_for_lot(self, lot):
domain = AND(
[
self._domain_move_lines_to_do_all(),
[
("lot_id", "=", lot.id),
# if the lot is in a package, the package must be scanned
("package_id", "=", False),
],
]
)
return self.env["stock.move.line"].search(domain, limit=1)
def _find_move_line_for_product(self, product):
domain = AND(
[
self._domain_move_lines_to_do_all(),
[
("product_id", "=", product.id),
# if the lot is in a package, the package must be scanned
("package_id", "=", False),
],
]
)
return self.env["stock.move.line"].search(domain, limit=1)
def _check_tray_type(self, barcode):
location = self.current_move_line_id.location_dest_id
tray_type = location.cell_in_tray_type_id
return barcode == tray_type.code
def update_step_description(self):
if self.current_operation_line_id:
descr = self.step_description()
else:
descr = _("No operations")
self.operation_descr = descr
def _assign_available_cell(self, tray_type):
locations = self.env["stock.location"].search(
[
("id", "child_of", self.location_id.id),
("cell_in_tray_type_id", "=", tray_type.id),
]
)
location = fields.first(
locations.filtered(lambda loc: not loc.tray_cell_contains_stock)
)
if location:
self.current_move_line_id.location_dest_id = location
return True
return False
def fetch_tray(self):
self.current_move_line_id.fetch_vertical_lift_tray_dest()
def process_current(self):
self.current_operation_line_id.process()
def button_release(self):
self.write({"operation_line_ids": [(2, self.current_operation_line_id.id)]})
return super().button_release()
def button_save(self):
if not (self and self.current_operation_line_id):
return
self.ensure_one()
self.process_current()
self.next_step()
def on_screen_open(self):
"""Called when the screen is open"""
if self.operation_line_ids:
self.select_next_move_line()
else:
return self.action_select_operations()
def select_next_move_line(self):
self.ensure_one()
next_operation = fields.first(self.operation_line_ids)
self.current_operation_line_id = next_operation
self.reset_steps()
if next_operation:
self.fetch_tray()
def action_select_operations(self):
self.ensure_one()
menu_xmlid = "stock_vertical_lift." "vertical_lift_operation_put_select_view"
select_model = self.env["vertical.lift.operation.put.select"]
select = select_model.create(
{
"operation_id": self.id,
"move_line_ids": [
(6, 0, self.mapped("operation_line_ids.move_line_id.id"))
],
}
)
return {
"type": "ir.actions.act_window",
"res_model": "vertical.lift.operation.put.select",
"views": [[self.env.ref(menu_xmlid).id, "form"]],
"name": _("Scan Operations"),
"target": "new",
"res_id": select.id,
}
class VerticalLiftOperationPutLine(models.Model):
_name = "vertical.lift.operation.put.line"
_description = "Vertical Lift Operation Put Line"
operation_id = fields.Many2one(
comodel_name="vertical.lift.operation.put", required=True, readonly=True
)
move_line_id = fields.Many2one(comodel_name="stock.move.line", readonly=True)
def process(self):
line = self.move_line_id
if line.state != "done":
line.qty_done = line.product_qty
line.move_id._action_done()
class VerticalLiftOperationPutSelect(models.TransientModel):
_name = "vertical.lift.operation.put.select"
_inherit = "barcodes.barcode_events_mixin"
_description = "Vertical Lift Operation Put Select"
operation_id = fields.Many2one(
comodel_name="vertical.lift.operation.put", required=True, readonly=True
)
move_line_ids = fields.Many2many(comodel_name="stock.move.line")
def _sync_lines(self):
self.operation_id.operation_line_ids.unlink()
operation_line_model = self.env["vertical.lift.operation.put.line"]
operation_line_model.create(
[
{"operation_id": self.operation_id.id, "move_line_id": move_line.id}
for move_line in self.move_line_ids
]
)
self.operation_id.select_next_move_line()
def action_validate(self):
self._sync_lines()
return {"type": "ir.actions.act_window_close"}
def _move_line_domain(self):
return [
("state", "=", "assigned"),
("location_dest_id", "child_of", self.operation_id.location_id.id),
]
def action_add_all(self):
move_lines = self.env["stock.move.line"].search(self._move_line_domain())
self.move_line_ids = move_lines
self._sync_lines()
return {"type": "ir.actions.act_window_close"}
def on_barcode_scanned(self, barcode):
self.ensure_one()
domain = self._move_line_domain()
domain = expression.AND([domain, [("product_id.barcode", "=", barcode)]])
move_lines = self.env["stock.move.line"].search(domain)
# note: on_barcode_scanned is called in an onchange, so 'self'
# is a NewID, we can't use 'write()' on it.
self.move_line_ids |= move_lines

View File

@@ -37,3 +37,12 @@ in Odoo for each physical shuttle. Depending of the subsidiary addons installed
(eg. Kardex), different options may be required (host address, ...). The base
addon only includes shuttles of kind "simulation" which will not send orders to
the hardware.
Put-away configuration
~~~~~~~~~~~~~~~~~~~~~~
If you want to use put-away in the vertical lift, the Receipts must have the
vertical lift view as destination. E.g. create put-away rules on the products
so when they arrive in WH/Stock, they are stored in WH/Stock/Vertical Lift. On
the put-away screen, when scanning the tray type to store, the destination will
be updated with an available cell of the same tray type in the current shuttle.

View File

@@ -3,6 +3,5 @@ access_vertical_lift_shuttle_stock_user,access_vertical_lift_shuttle stock user,
access_vertical_lift_shuttle_manager,access_vertical_lift_shuttle stock manager,model_vertical_lift_shuttle,stock.group_stock_manager,1,1,1,1
access_vertical_lift_operation_pick_stock_user,access_vertical_lift_operation_pick stock user,model_vertical_lift_operation_pick,stock.group_stock_user,1,1,1,1
access_vertical_lift_operation_put_stock_user,access_vertical_lift_operation_put stock user,model_vertical_lift_operation_put,stock.group_stock_user,1,1,1,1
access_vertical_lift_operation_put_line_stock_user,access_vertical_lift_operation_put_line stock user,model_vertical_lift_operation_put_line,stock.group_stock_user,1,1,1,1
access_vertical_lift_operation_inventory_stock_user,access_vertical_lift_operation_inventory stock user,model_vertical_lift_operation_inventory,stock.group_stock_user,1,1,1,1
access_vertical_lift_command,vertical_lift_command,model_vertical_lift_command,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_vertical_lift_shuttle_manager access_vertical_lift_shuttle stock manager model_vertical_lift_shuttle stock.group_stock_manager 1 1 1 1
4 access_vertical_lift_operation_pick_stock_user access_vertical_lift_operation_pick stock user model_vertical_lift_operation_pick stock.group_stock_user 1 1 1 1
5 access_vertical_lift_operation_put_stock_user access_vertical_lift_operation_put stock user model_vertical_lift_operation_put stock.group_stock_user 1 1 1 1
access_vertical_lift_operation_put_line_stock_user access_vertical_lift_operation_put_line stock user model_vertical_lift_operation_put_line stock.group_stock_user 1 1 1 1
6 access_vertical_lift_operation_inventory_stock_user access_vertical_lift_operation_inventory stock user model_vertical_lift_operation_inventory stock.group_stock_user 1 1 1 1
7 access_vertical_lift_command vertical_lift_command model_vertical_lift_command base.group_user 1 0 0 0

View File

@@ -18,6 +18,8 @@ class VerticalLiftCase(common.LocationTrayTypeCase):
cls.vertical_lift_loc = cls.env.ref(
"stock_vertical_lift.stock_location_vertical_lift"
)
cls.stock_location = cls.env.ref("stock.stock_location_stock")
cls.customers_location = cls.env.ref("stock.stock_location_customers")
cls.location_1a = cls.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a"
)
@@ -49,6 +51,12 @@ class VerticalLiftCase(common.LocationTrayTypeCase):
def _update_qty_in_location(self, location, product, quantity):
self.env["stock.quant"]._update_available_quantity(product, location, quantity)
def _open_screen(self, mode, shuttle=None):
getattr(shuttle or self.shuttle, "switch_{}".format(mode))()
# opening the screen can do some initialization for the steps
action = (shuttle or self.shuttle).action_open_screen()
return self.env[action["res_model"]].browse(action["res_id"])
@classmethod
def _create_simple_picking_out(cls, product, quantity):
stock_loc = cls.env.ref("stock.stock_location_stock")
@@ -133,15 +141,17 @@ class VerticalLiftCase(common.LocationTrayTypeCase):
inventory.action_start()
return inventory
def _test_button_release(self, move_line):
def _test_button_release(self, move_line, expected_state):
# for the test, we'll consider our last line has been delivered
move_line.qty_done = move_line.product_qty
move_line.move_id._action_done()
# release, no further operation in queue
operation = self.shuttle._operation_for_mode()
# the release button can be used only in the state... release
operation.state = "release"
result = operation.button_release()
self.assertEqual(operation.state, expected_state)
self.assertFalse(operation.current_move_line_id)
self.assertEqual(operation.operation_descr, _("No operations"))
expected_result = {
"effect": {
"fadeout": "slow",

View File

@@ -35,8 +35,7 @@ class TestInventory(VerticalLiftCase):
self._update_qty_in_location(self.location_2a_x1y1, self.product_socks, 10)
self._create_inventory([(self.location_2a_x1y1, self.product_socks)])
self.shuttle.switch_inventory()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("inventory")
self.assertEqual(operation.number_of_ops, 2)
self.assertEqual(operation.number_of_ops_all, 3)
@@ -45,16 +44,17 @@ class TestInventory(VerticalLiftCase):
inventory = self._create_inventory(
[(self.location_1a_x1y1, self.product_socks)]
)
self.shuttle.switch_inventory()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("inventory")
self.assertEqual(operation.state, "quantity")
self.assertEqual(operation.current_inventory_line_id, inventory.line_ids)
# test the happy path, quantity is correct
operation.quantity_input = 10.0
result = operation.button_save()
# state is reset
self.assertEqual(operation.state, "quantity")
# noop because we have no further lines
self.assertEqual(operation.state, "noop")
self.assertFalse(operation.current_inventory_line_id)
self.assertEqual(operation.operation_descr, _("No operations"))
self.assertTrue(inventory.line_ids.vertical_lift_done)
self.assertEqual(inventory.state, "done")
expected_result = {
@@ -72,8 +72,7 @@ class TestInventory(VerticalLiftCase):
inventory = self._create_inventory(
[(self.location_1a_x1y1, self.product_socks)]
)
self.shuttle.switch_inventory()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("inventory")
line = operation.current_inventory_line_id
self.assertEqual(line, inventory.line_ids)
@@ -83,9 +82,6 @@ class TestInventory(VerticalLiftCase):
self.assertEqual(operation.quantity_input, 0.0)
self.assertEqual(operation.state, "confirm_wrong_quantity")
self.assertEqual(operation.current_inventory_line_id, line)
self.assertEqual(
operation.operation_descr, _("The quantity does not match, are you sure?")
)
# entering the same quantity a second time validates
operation.quantity_input = 12.0
@@ -94,3 +90,26 @@ class TestInventory(VerticalLiftCase):
self.assertTrue(inventory.line_ids.vertical_lift_done)
self.assertEqual(inventory.state, "done")
def test_inventory_next_line(self):
self._update_qty_in_location(self.location_1a_x1y1, self.product_socks, 10)
self._update_qty_in_location(self.location_1a_x2y1, self.product_recovery, 10)
inventory = self._create_inventory(
[
(self.location_1a_x1y1, self.product_socks),
(self.location_1a_x2y1, self.product_recovery),
]
)
inventory_lines = inventory.line_ids
operation = self._open_screen("inventory")
operation.quantity_input = 10.0
line1 = operation.current_inventory_line_id
result = operation.button_save()
self.assertFalse(result) # no rainbow man
# go to next line
remaining_line = inventory_lines - line1
self.assertEqual(operation.state, "quantity")
self.assertEqual(operation.current_inventory_line_id, remaining_line)
self.assertEqual(operation.last_quantity_input, 0.0)
self.assertEqual(operation.quantity_input, 0.0)

View File

@@ -1,8 +1,6 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _
from .common import VerticalLiftCase
@@ -34,23 +32,22 @@ class TestPick(VerticalLiftCase):
self.assertEqual(action["res_id"], operation.id)
def test_pick_select_next_move_line(self):
self.shuttle.mode = "pick"
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("pick")
operation.select_next_move_line()
self.assertEqual(operation.current_move_line_id, self.out_move_line)
self.assertEqual(operation.operation_descr, _("Scan New Destination Location"))
self.assertEqual(operation.state, "scan_destination")
def test_pick_save(self):
self.shuttle.switch_pick()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("pick")
# assume we already scanned the destination, current state is save
operation.state = "save"
operation.current_move_line_id = self.out_move_line
operation.button_save()
self.assertEqual(operation.current_move_line_id.state, "done")
self.assertEqual(operation.operation_descr, _("Release"))
self.assertEqual(operation.state, "release")
def test_pick_related_fields(self):
self.shuttle.switch_pick()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("pick")
ml = operation.current_move_line_id = self.out_move_line
# Trays related fields
@@ -148,8 +145,8 @@ class TestPick(VerticalLiftCase):
self.assertEqual(operation2.number_of_ops_all, 6)
def test_on_barcode_scanned(self):
self.shuttle.switch_pick()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("pick")
self.assertEqual(operation.state, "scan_destination")
move_line = operation.current_move_line_id
current_destination = move_line.location_dest_id
stock_location = self.env.ref("stock.stock_location_stock")
@@ -158,14 +155,14 @@ class TestPick(VerticalLiftCase):
)
operation.on_barcode_scanned(stock_location.barcode)
self.assertEqual(move_line.location_dest_id, stock_location)
self.assertEqual(operation.state, "save")
def test_button_release(self):
self.shuttle.switch_pick()
self._test_button_release(self.out_move_line)
self._open_screen("pick")
self._test_button_release(self.out_move_line, "noop")
def test_process_current_pick(self):
self.shuttle.switch_pick()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("pick")
operation.current_move_line_id = self.out_move_line
qty_to_process = self.out_move_line.product_qty
operation.process_current()
@@ -173,8 +170,7 @@ class TestPick(VerticalLiftCase):
self.assertEqual(self.out_move_line.qty_done, qty_to_process)
def test_matrix(self):
self.shuttle.switch_pick()
operation = self.shuttle._operation_for_mode()
operation = self._open_screen("pick")
operation.current_move_line_id = self.out_move_line
location = self.out_move_line.location_id
# offset by -1 because the fields are for humans

View File

@@ -13,17 +13,7 @@ class TestPut(VerticalLiftCase):
)
cls.picking_in.action_confirm()
cls.in_move_line = cls.picking_in.move_line_ids
cls.in_move_line.location_dest_id = cls.location_1a_x3y1
def _select_move_lines(self, shuttle, move_lines=None):
select_model = self.env["vertical.lift.operation.put.select"]
operation = shuttle._operation_for_mode()
select = select_model.create({"operation_id": operation.id})
if move_lines:
select.move_line_ids = [(6, 0, move_lines.ids)]
else:
select.action_add_all()
select._sync_lines()
cls.in_move_line.location_dest_id = cls.shuttle.location_id
def test_put_action_open_screen(self):
self.shuttle.switch_put()
@@ -41,127 +31,105 @@ class TestPut(VerticalLiftCase):
self.env["stock.move.line"].browse(),
)
def test_select_from_barcode(self):
self.shuttle.switch_put()
self.picking_in.action_cancel()
put1 = self._create_simple_picking_in(
self.product_socks, 10, self.location_1a_x1y1
)
put1.action_confirm()
put2 = self._create_simple_picking_in(
self.product_recovery, 10, self.location_1a_x2y1
)
put2.action_confirm()
select_model = self.env["vertical.lift.operation.put.select"]
operation = self.shuttle._operation_for_mode()
select = select_model.create({"operation_id": operation.id})
select.on_barcode_scanned(self.product_socks.barcode)
self.assertRecordValues(select, [{"move_line_ids": put1.move_line_ids.ids}])
select.on_barcode_scanned(self.product_recovery.barcode)
self.assertRecordValues(
select, [{"move_line_ids": (put1.move_line_ids | put2.move_line_ids).ids}]
)
select.action_validate()
self.assertEqual(len(operation.operation_line_ids), 2)
self.assertRecordValues(
operation.mapped("operation_line_ids"),
[
{"move_line_id": put1.move_line_ids.id},
{"move_line_id": put2.move_line_ids.id},
],
)
def test_no_select_from_barcode_outside_location(self):
self.shuttle.switch_put()
self.picking_in.action_cancel()
location = self.env.ref("stock.location_refrigerator_small")
put1 = self._create_simple_picking_in(self.product_socks, 10, location)
put1.action_confirm()
select_model = self.env["vertical.lift.operation.put.select"]
operation = self.shuttle._operation_for_mode()
select = select_model.create({"operation_id": operation.id})
select.on_barcode_scanned(self.product_socks.barcode)
# the move line is outside of the vertical lift, should not be
# selected
self.assertRecordValues(select, [{"move_line_ids": []}])
def test_put_count_move_lines(self):
self.shuttle.switch_put()
self.picking_in.action_cancel()
put1 = self._create_simple_picking_in(
self.product_socks, 10, self.location_1a_x1y1
)
put1.action_confirm()
put2 = self._create_simple_picking_in(
self.product_recovery, 10, self.location_1a_x2y1
self.product_recovery, 10, self.vertical_lift_loc
)
put2.action_confirm()
put3 = self._create_simple_picking_in(
self.product_recovery, 10, self.location_2a_x1y1
self.product_recovery, 10, self.vertical_lift_loc
)
put3.action_confirm()
operation = self.shuttle._operation_for_mode()
self._select_move_lines(self.shuttle)
operation = self._open_screen("put")
shuttle2 = self.env.ref(
"stock_vertical_lift.stock_vertical_lift_demo_shuttle_2"
)
shuttle2.switch_put()
operation2 = shuttle2._operation_for_mode()
self._select_move_lines(shuttle2)
operation2 = self._open_screen("put", shuttle=shuttle2)
self.assertEqual(operation.number_of_ops, 2)
# we don't really care about the "number_of_ops" for the
# put-away, as the move lines are supposed to have the whole
# whole shuttle view as destination
self.assertEqual(operation.number_of_ops, 1)
self.assertEqual(operation.number_of_ops_all, 3)
self.assertEqual(operation2.number_of_ops, 1)
self.assertEqual(operation2.number_of_ops, 0)
self.assertEqual(operation2.number_of_ops_all, 3)
def test_process_current_put(self):
self.shuttle.switch_put()
operation = self.shuttle._operation_for_mode()
self._select_move_lines(self.shuttle, self.in_move_line)
self.assertEqual(operation.current_move_line_id, self.in_move_line)
qty_to_process = self.in_move_line.product_qty
operation.process_current()
self.assertEqual(self.in_move_line.state, "done")
self.assertEqual(self.in_move_line.qty_done, qty_to_process)
def test_transition_start(self):
operation = self._open_screen("put")
# we begin with an empty screen, user has to scan a package, product,
# or lot
self.assertEqual(operation.state, "scan_source")
def test_transition_reset(self):
self.shuttle.switch_put()
operation = self.shuttle._operation_for_mode()
self._select_move_lines(self.shuttle, self.in_move_line)
operation.state = "scan_tray_type"
operation.reset_steps()
self.assertEqual(operation.step(), "scan_product")
def test_transition_scan_product(self):
self.shuttle.switch_put()
operation = self.shuttle._operation_for_mode()
self._select_move_lines(self.shuttle, self.in_move_line)
operation.state = "scan_product"
def test_transition_scan_source_to_scan_tray_type(self):
operation = self._open_screen("put")
self.assertEqual(operation.state, "scan_source")
# wrong barcode, nothing happens
operation.on_barcode_scanned("foo")
self.assertEqual(operation.state, "scan_source")
# product scanned, move to next step
operation.on_barcode_scanned(self.product_socks.barcode)
self.assertEqual(operation.step(), "scan_tray_type")
self.assertEqual(operation.state, "scan_tray_type")
self.assertEqual(operation.current_move_line_id, self.in_move_line)
def test_transition_scan_tray_type(self):
self.shuttle.switch_put()
operation = self.shuttle._operation_for_mode()
self._select_move_lines(self.shuttle, self.in_move_line)
def test_transition_scan_tray_type_to_save(self):
operation = self._open_screen("put")
# assume we already scanned the product
operation.state = "scan_tray_type"
operation.current_move_line_id = self.in_move_line
# wrong barcode, nothing happens
operation.on_barcode_scanned("foo")
# tray type scanned, move to next step
operation.on_barcode_scanned(operation.tray_type_id.code)
self.assertEqual(operation.step(), "save")
operation.on_barcode_scanned(self.location_1a.tray_type_id.code)
self.assertEqual(operation.state, "save")
# a cell has been set
self.assertTrue(
self.in_move_line.location_dest_id
in self.shuttle.location_id.child_ids.child_ids
)
def test_transition_scan_tray_type_no_empty_cell(self):
operation = self._open_screen("put")
# assume we already scanned the product
operation.state = "scan_tray_type"
operation.current_move_line_id = self.in_move_line
# create a tray type without location, which is the same as if all the
# locations of a tray type were full
new_tray_type = self.env["stock.location.tray.type"].create(
{"name": "new tray type", "code": "test", "rows": 1, "cols": 1}
)
operation.on_barcode_scanned(new_tray_type.code)
# should stay the same state
self.assertEqual(operation.state, "scan_tray_type")
# destination not changed
self.assertEqual(self.in_move_line.location_dest_id, self.shuttle.location_id)
def test_transition_save(self):
self.shuttle.switch_put()
operation = self.shuttle._operation_for_mode()
self._select_move_lines(self.shuttle, self.in_move_line)
operation = self._open_screen("put")
# first steps of the workflow are done
operation.current_move_line_id = self.in_move_line
operation.current_move_line_id.location_dest_id = self.location_1a_x1y1
operation.state = "save"
qty_to_process = self.in_move_line.product_qty
operation.button_save()
self.assertEqual(operation.step(), "release")
self.assertEqual(self.in_move_line.state, "done")
self.assertEqual(self.in_move_line.qty_done, qty_to_process)
def test_transition_button_release(self):
self.shuttle.switch_put()
self._test_button_release(self.in_move_line)
operation = self._open_screen("put")
move_line = self.in_move_line
# first steps of the workflow are done
operation.current_move_line_id = move_line
operation.current_move_line_id.location_dest_id = self.location_1a_x1y1
# for the test, we'll consider our last line has been delivered
move_line.qty_done = move_line.product_qty
move_line.move_id._action_done()
operation = self._open_screen("put")
operation.button_release()
self.assertEqual(operation.state, "scan_source")
self.assertFalse(operation.current_move_line_id)

View File

@@ -37,7 +37,8 @@
</div>
<div class="o_shuttle_header_right o_shuttle_header_content">
<label for="number_of_ops" />
<field name="number_of_ops" readonly="1" /> /
<field name="number_of_ops" readonly="1" />
<span>/</span>
<field name="number_of_ops_all" readonly="1" />
</div>
</div>
@@ -72,6 +73,7 @@
icon="fa-check"
class="btn-primary"
barcode_trigger="save"
attrs="{'invisible': [('state', '!=', 'save')]}"
/>
<!-- will react on barcode 'O-BTN.release -->
<button
@@ -80,13 +82,14 @@
string="Release"
class="btn-primary"
barcode_trigger="release"
attrs="{'invisible': [('state', '!=', 'release')]}"
/>
</div>
</div>
</div>
<div class="o_shuttle_operation bg-primary jumbotron jumbotron-fluid">
<div class="container">
<field name="operation_descr" />
<field name="state" invisible="0" readonly="True" />
</div>
</div>
<div class="o_shuttle_data">
@@ -134,7 +137,6 @@
options="{'no_open': True}"
/>
</div>
<!-- TODO change for pick and put? -->
<label for="location_dest_id" />
<div>
<field
@@ -179,8 +181,12 @@
</div>
</div>
<!-- on the right of the screen -->
<div class="o_shuttle_data_content o_shuttle_tray">
<div
class="o_shuttle_data_content o_shuttle_tray"
attrs="{'invisible': [('tray_type_id', '=', False)]}"
>
<group col="1">
<field name="tray_type_id" invisible="1" />
<field name="tray_name" />
<field name="tray_type_code" />
<field name="tray_y" />

View File

@@ -24,6 +24,11 @@
<form position="inside">
<field name="state" invisible="1" />
</form>
<button name="button_save" position="attributes">
<attribute
name="attrs"
>{'invisible': [('state', 'not in', ('quantity', 'confirm_wrong_quantity'))]}</attribute>
</button>
<button name="button_release" position="attributes">
<attribute name="invisible">1</attribute>
</button>
@@ -72,6 +77,7 @@
name="quantity_input"
default_focus="1"
class="oe_inline"
attrs="{'readonly': [('state', 'not in', ('quantity', 'confirm_wrong_quantity'))]}"
/>
<field
name="product_uom_id"

View File

@@ -22,68 +22,16 @@
<attribute name="string">Put-Away Screen</attribute>
</form>
<xpath
expr="//div[hasclass('o_shuttle_actions')]/div[hasclass('o_shuttle_content_left')]"
position="inside"
expr="//div[hasclass('o_shuttle_header_right')]/field[@name='number_of_ops']"
position="attributes"
>
<attribute name="invisible">1</attribute>
</xpath>
<xpath
expr="//div[hasclass('o_shuttle_header_right')]/field[@name='number_of_ops']/following-sibling::span"
position="replace"
>
<button
name="action_select_operations"
type="object"
class="btn-primary"
string="Select Operations"
aria-label="Select Operations"
title="Select Operations"
/>
</xpath>
<field name="_barcode_scanned" position="before">
<!-- these fields have to be in the view otherwise they
would be empty in the record sent to the _barcode_scanned
onchange method
-->
<field name="state" invisible="1" />
<field name="current_operation_line_id" invisible="1" />
</field>
</field>
</record>
<record id="vertical_lift_operation_put_select_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.put.select.view</field>
<field name="model">vertical.lift.operation.put.select</field>
<field name="arch" type="xml">
<form string="Select" class="o_vlift_shuttle_popup">
<field name="move_line_ids">
<tree create="0" edit="0">
<field name="product_id" />
</tree>
<form>
<group>
<field name="product_id" readonly="1" />
<field name="product_uom_qty" readonly="1" />
<field name="qty_done" readonly="1" />
</group>
</form>
</field>
<!-- this field has to be in the view otherwise it
would be empty in the record sent to the _barcode_scanned
onchange method
-->
<field name="operation_id" invisible="1" />
<field name="_barcode_scanned" widget="barcode_handler" />
<footer>
<button
name="action_validate"
string="Validate"
type="object"
class="btn-primary"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
<button
name="action_add_all"
string="Add all"
type="object"
groups="base.group_no_one"
class="btn-secondary"
/>
</footer>
</form>
</field>
</record>
</odoo>