Implement inventory screen

This commit is contained in:
Guewen Baconnier
2019-10-15 15:39:05 +02:00
parent bcfb269e2f
commit 0c9f88d953
10 changed files with 566 additions and 83 deletions

View File

@@ -3,6 +3,7 @@ from . import vertical_lift_operation_base
from . import vertical_lift_operation_pick
from . import vertical_lift_operation_put
from . import vertical_lift_operation_inventory
from . import stock_inventory
from . import stock_location
from . import stock_move
from . import stock_move_line

View File

@@ -0,0 +1,27 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class InventoryLine(models.Model):
_inherit = "stock.inventory.line"
vertical_lift_done = fields.Boolean(default=False)
# Field used to sort lines by tray on the inventory scan screen, so entire
# trays are processed one after the other
vertical_lift_tray_id = fields.Many2one(
comodel_name="stock.location",
compute="_compute_vertical_lift_tray_id",
readonly=True,
store=True,
)
@api.depends("location_id.vertical_lift_kind")
def _compute_vertical_lift_tray_id(self):
for line in self:
if line.location_id.vertical_lift_kind == "cell":
# The parent of the cell is the tray.
line.vertical_lift_tray_id = line.location_id.location_id
else:
line.vertical_lift_tray_id = False

View File

@@ -19,6 +19,13 @@ class VerticalLiftOperationBase(models.AbstractModel):
location_id = fields.Many2one(
related="shuttle_id.location_id", readonly=True
)
number_of_ops = fields.Integer(
compute="_compute_number_of_ops", string="Number of Operations"
)
number_of_ops_all = fields.Integer(
compute="_compute_number_of_ops_all",
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
@@ -32,6 +39,16 @@ class VerticalLiftOperationBase(models.AbstractModel):
)
]
@api.depends()
def _compute_number_of_ops(self):
for record in self:
record.number_of_ops = 0
@api.depends()
def _compute_number_of_ops_all(self):
for record in self:
record.number_of_ops_all = 0
def on_barcode_scanned(self, barcode):
self.ensure_one()
# to implement in sub-classes
@@ -51,6 +68,35 @@ 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, ...)"""
raise NotImplementedError
def button_release(self):
"""Release the operation, go to the next"""
raise NotImplementedError
def _render_product_packagings(self, product):
values = {
"packagings": [
{"name": pkg.name, "qty": pkg.qty, "unit": product.uom_id.name}
for pkg in product.packaging_ids
]
}
content = self.env["ir.qweb"].render(
"stock_vertical_lift.packagings", values
)
return content
def _get_tray_qty(self, product, location):
quants = self.env["stock.quant"].search(
[
("location_id", "=", location.id),
("product_id", "=", product.id),
]
)
return sum(quants.mapped("quantity"))
class VerticalLiftOperationTransfer(models.AbstractModel):
"""Base model for shuttle pick and put operations"""
@@ -63,14 +109,6 @@ class VerticalLiftOperationTransfer(models.AbstractModel):
comodel_name="stock.move.line", readonly=True
)
number_of_ops = fields.Integer(
compute="_compute_number_of_ops", string="Number of Operations"
)
number_of_ops_all = fields.Integer(
compute="_compute_number_of_ops_all",
string="Number of Operations in all shuttles",
)
tray_location_id = fields.Many2one(
comodel_name="stock.location",
compute="_compute_tray_data",
@@ -137,21 +175,10 @@ class VerticalLiftOperationTransfer(models.AbstractModel):
def _compute_product_packagings(self):
for record in self:
if not record.current_move_line_id:
record.product_packagings = ""
continue
product = record.current_move_line_id.product_id
values = {
"packagings": [
{
"name": pkg.name,
"qty": pkg.qty,
"unit": product.uom_id.name,
}
for pkg in product.packaging_ids
]
}
content = self.env["ir.qweb"].render(
"stock_vertical_lift.packagings", values
)
content = self._render_product_packagings(product)
record.product_packagings = content
@api.depends()
@@ -168,17 +195,12 @@ class VerticalLiftOperationTransfer(models.AbstractModel):
def _compute_tray_qty(self):
for record in self:
if not (record.tray_location_id and record.current_move_line_id):
record.tray_qty = 0.
continue
product = record.current_move_line_id.product_id
quants = self.env["stock.quant"].search(
[
("location_id", "=", record.tray_location_id.id),
("product_id", "=", product.id),
]
)
record.tray_qty = sum(quants.mapped("quantity"))
location = record.tray_location_id
record.tray_qty = self._get_tray_qty(product, location)
# depends of the quantity so we can't have all triggers
@api.depends("current_move_line_id")
def _compute_tray_data(self):
for record in self:
@@ -245,4 +267,4 @@ class VerticalLiftOperationTransfer(models.AbstractModel):
self.operation_descr = _("Release")
def fetch_tray(self):
return
raise NotImplementedError

View File

@@ -1,10 +1,261 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
from odoo import _, api, fields, models
from odoo.addons.base_sparse_field.models.fields import Serialized
from odoo.tools import float_compare
# TODO handle autofocus + easy way to validate for the input field
class VerticalLiftOperationInventory(models.Model):
_name = 'vertical.lift.operation.inventory'
_inherit = 'vertical.lift.operation.base'
_description = 'Vertical Lift Operation Inventory'
_name = "vertical.lift.operation.inventory"
_inherit = "vertical.lift.operation.base"
_description = "Vertical Lift Operation Inventory"
current_inventory_line_id = fields.Many2one(
comodel_name="stock.inventory.line", readonly=True
)
quantity_input = fields.Float()
# 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",
compute="_compute_tray_data",
string="Tray Location",
)
tray_name = fields.Char(compute="_compute_tray_data", string="Tray Name")
tray_type_id = fields.Many2one(
comodel_name="stock.location.tray.type",
compute="_compute_tray_data",
string="Tray Type",
)
tray_type_code = fields.Char(
compute="_compute_tray_data", string="Tray Code"
)
tray_x = fields.Integer(string="X", compute="_compute_tray_data")
tray_y = fields.Integer(string="Y", compute="_compute_tray_data")
tray_matrix = Serialized(string="Cells", compute="_compute_tray_data")
tray_qty = fields.Float(
string="Stock Quantity", compute="_compute_tray_qty"
)
# current operation information
inventory_id = fields.Many2one(
related="current_inventory_line_id.inventory_id", readonly=True
)
product_id = fields.Many2one(
related="current_inventory_line_id.product_id", readonly=True
)
product_uom_id = fields.Many2one(
related="current_inventory_line_id.product_uom_id", readonly=True
)
product_qty = fields.Float(
related="current_inventory_line_id.product_qty", readonly=True
)
product_packagings = fields.Html(
string="Packaging", compute="_compute_product_packagings"
)
package_id = fields.Many2one(
related="current_inventory_line_id.package_id", readonly=True
)
lot_id = fields.Many2one(
related="current_inventory_line_id.prod_lot_id", readonly=True
)
@api.depends("current_inventory_line_id")
def _compute_tray_data(self):
for record in self:
location = record.current_inventory_line_id.location_id
tray_type = location.location_id.tray_type_id
# this is the current cell
record.tray_location_id = location.id
# name of the tray where the cell is
record.tray_name = location.location_id.name
record.tray_type_id = tray_type.id
record.tray_type_code = tray_type.code
record.tray_x = location.posx
record.tray_y = location.posy
record.tray_matrix = location.tray_matrix
@api.depends("current_inventory_line_id.product_id.packaging_ids")
def _compute_product_packagings(self):
for record in self:
if not record.current_inventory_line_id:
record.product_packagings = ""
continue
product = record.current_inventory_line_id.product_id
content = self._render_product_packagings(product)
record.product_packagings = content
@api.depends("tray_location_id", "current_inventory_line_id.product_id")
def _compute_tray_qty(self):
for record in self:
if not (
record.tray_location_id and record.current_inventory_line_id
):
record.tray_qty = 0.
continue
product = record.current_inventory_line_id.product_id
location = record.tray_location_id
record.tray_qty = self._get_tray_qty(product, location)
def _compute_number_of_ops(self):
for record in self:
line_model = self.env["stock.inventory.line"]
record.number_of_ops = line_model.search_count(
self._domain_inventory_lines_to_do()
)
def _compute_number_of_ops_all(self):
for record in self:
line_model = self.env["stock.inventory.line"]
record.number_of_ops_all = line_model.search_count(
self._domain_inventory_lines_to_do_all()
)
def _domain_inventory_lines_to_do(self):
return [
("location_id", "child_of", self.location_id.id),
("state", "=", "confirm"),
("vertical_lift_done", "=", False),
]
def _domain_inventory_lines_to_do_all(self):
shuttle_locations = self.env["stock.location"].search(
[("vertical_lift_kind", "=", "view")]
)
return [
("location_id", "child_of", shuttle_locations.ids),
("state", "=", "confirm"),
("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.,
"last_quantity_input": 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 _has_identical_quantity(self):
line = self.current_inventory_line_id
return (
float_compare(
line.theoretical_qty,
self.quantity_input,
precision_rounding=line.product_uom_id.rounding,
)
== 0
)
def _process_quantity(self):
if self.step() == "quantity":
if self._has_identical_quantity():
self.step_to("save")
return True
else:
self.last_quantity_input = self.quantity_input
self.quantity_input = 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 fetch_tray(self):
location = self.current_inventory_line_id.location_id
location.fetch_vertical_lift_tray()
def select_next_inventory_line(self):
self.ensure_one()
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()

View File

@@ -1,3 +1,8 @@
* Complete Pick screen and workflow (currently enough for a demo, not for production)
* Implement Put-away screen and workflow
* Implement Inventory screen and workflow
* Complete screen workflows (currently enough for a demo, not for production)
* Inventory: find a way to have a nice autofocus for quantity, still compatible
with barcode scanner (Odoo disables the autofocus when using barcode, which
makes sense)
* Put-away: handle packages
* Handle "multi-shuttle" put-away
* Create glue module for product_expiry
* Challenge the save + release buttons and workflow

View File

@@ -21,6 +21,47 @@ class VerticalLiftCase(common.LocationTrayTypeCase):
cls.vertical_lift_loc = cls.env.ref(
'stock_vertical_lift.stock_location_vertical_lift'
)
cls.location_1a = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a"
)
cls.location_1a_x1y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x1y1"
)
cls.location_1a_x2y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x2y1"
)
cls.location_1a_x3y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x3y1"
)
cls.location_1a_x1y2 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x1y2"
)
cls.location_1b_x1y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1b_x1y1"
)
cls.location_1b_x1y2 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1b_x1y2"
)
cls.location_2a = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_2a"
)
cls.location_2a_x1y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_2a_x1y1"
)
def _update_qty_in_location(self, location, product, quantity):
self.env["stock.quant"]._update_available_quantity(
product, location, quantity
)
@classmethod
def _create_simple_picking_out(cls, product, quantity):
@@ -81,6 +122,25 @@ class VerticalLiftCase(common.LocationTrayTypeCase):
}
)
@classmethod
def _create_inventory(self, products):
"""Create a draft inventory
Products is a list of tuples (bin location, product).
"""
values = {
'name': 'Test Inventory',
'filter': 'partial',
'line_ids': [(0, 0, {
'product_id': product.id,
'product_uom_id': product.uom_id.id,
'location_id': location.id
}) for location, product in products]
}
inventory = self.env['stock.inventory'].create(values)
inventory.action_start()
return inventory
def _test_button_release(self, move_line):
# for the test, we'll consider our last line has been delivered
move_line.qty_done = move_line.product_qty

View File

@@ -1,31 +1,110 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import unittest
from odoo import _
from .common import VerticalLiftCase
class TestInventory(VerticalLiftCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.picking_out = cls.env.ref(
"stock_vertical_lift.stock_picking_out_demo_vertical_lift_1"
)
# we have a move line to pick created by demo picking
# stock_picking_out_demo_vertical_lift_1
cls.out_move_line = cls.picking_out.move_line_ids
def test_switch_inventory(self):
self.shuttle.switch_inventory()
self.assertEqual(self.shuttle.mode, "inventory")
self.assertEqual(
self.shuttle._operation_for_mode().current_inventory_line_id,
self.env["stock.inventory.line"].browse(),
)
@unittest.skip("Not implemented")
def test_inventory_count_move_lines(self):
pass
@unittest.skip("Not implemented")
def test_process_current_inventory(self):
# test to implement when the code is implemented
def test_inventory_action_open_screen(self):
self.shuttle.switch_inventory()
action = self.shuttle.action_open_screen()
operation = self.shuttle._operation_for_mode()
self.assertEqual(action["type"], "ir.actions.act_window")
self.assertEqual(
action["res_model"], "vertical.lift.operation.inventory"
)
self.assertEqual(action["res_id"], operation.id)
def test_inventory_count_ops(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
)
self._create_inventory(
[
(self.location_1a_x1y1, self.product_socks),
(self.location_1a_x2y1, self.product_recovery),
]
)
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()
self.assertEqual(operation.number_of_ops, 2)
self.assertEqual(operation.number_of_ops_all, 3)
def test_process_current_inventory(self):
self._update_qty_in_location(
self.location_1a_x1y1, self.product_socks, 10
)
inventory = self._create_inventory(
[(self.location_1a_x1y1, self.product_socks)]
)
self.shuttle.switch_inventory()
operation = self.shuttle._operation_for_mode()
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")
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 = {
"effect": {
"fadeout": "slow",
"message": _("Congrats, you cleared the queue!"),
"img_url": "/web/static/src/img/smile.svg",
"type": "rainbow_man",
}
}
self.assertEqual(result, expected_result)
def test_wrong_quantity(self):
self._update_qty_in_location(
self.location_1a_x1y1, self.product_socks, 10
)
inventory = self._create_inventory(
[(self.location_1a_x1y1, self.product_socks)]
)
self.shuttle.switch_inventory()
operation = self.shuttle._operation_for_mode()
line = operation.current_inventory_line_id
self.assertEqual(line, inventory.line_ids)
operation.quantity_input = 12.0
operation.button_save()
self.assertEqual(operation.last_quantity_input, 12.0)
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
operation.button_save()
self.assertFalse(operation.current_inventory_line_id)
self.assertTrue(inventory.line_ids.vertical_lift_done)
self.assertEqual(inventory.state, "done")

View File

@@ -13,22 +13,6 @@ class TestPut(VerticalLiftCase):
)
cls.picking_in.action_confirm()
cls.in_move_line = cls.picking_in.move_line_ids
cls.location_1a_x1y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x1y1"
)
cls.location_1a_x2y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x2y1"
)
cls.location_1a_x3y1 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x3y1"
)
cls.location_1a_x1y2 = cls.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_1a_x1y2"
)
cls.in_move_line.location_dest_id = cls.location_1a_x3y1
def _select_move_lines(self, shuttle, move_lines=None):
@@ -107,10 +91,6 @@ class TestPut(VerticalLiftCase):
def test_put_count_move_lines(self):
self.shuttle.switch_put()
self.picking_in.action_cancel()
location_2a_x1y1 = self.env.ref(
"stock_vertical_lift."
"stock_location_vertical_lift_demo_tray_2a_x1y1"
)
put1 = self._create_simple_picking_in(
self.product_socks, 10, self.location_1a_x1y1
)
@@ -120,7 +100,7 @@ class TestPut(VerticalLiftCase):
)
put2.action_confirm()
put3 = self._create_simple_picking_in(
self.product_recovery, 10, location_2a_x1y1
self.product_recovery, 10, self.location_2a_x1y1
)
put3.action_confirm()
operation = self.shuttle._operation_for_mode()

View File

@@ -37,6 +37,9 @@
<field name="mode" readonly="1"/>
</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_all" readonly="1"/>
</div>
</div>
<div class="o_shuttle_actions">
@@ -86,11 +89,6 @@
<field name="inherit_id" ref="vertical_lift_operation_base_screen_view"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('o_shuttle_header_right')]" position="inside" >
<label for="number_of_ops"/>
<field name="number_of_ops" readonly="1"/> /
<field name="number_of_ops_all" readonly="1"/>
</xpath>
<xpath expr="//div[hasclass('o_shuttle_data')]" position="attributes" >
<attribute name="attrs" >{'invisible': [('current_move_line_id', '=', False)]}</attribute>
</xpath>

View File

@@ -23,8 +23,68 @@
<form position="attributes">
<attribute name="string">Inventory Screen</attribute>
</form>
<form position="inside">
<field name="state" invisible="1"/>
</form>
<button name="button_release" position="attributes">
<attribute name="invisible">1</attribute>
</button>
<xpath expr="//div[hasclass('o_shuttle_data')]" position="attributes" >
<attribute name="attrs" >{'invisible': [('current_inventory_line_id', '=', False)]}</attribute>
</xpath>
<xpath expr="//div[hasclass('o_shuttle_data')]" position="inside" >
<span>Not implemented</span>
<!-- on the left of the screen -->
<div class="o_shuttle_data_content o_shuttle_move">
<div>
<group>
<field name="current_inventory_line_id" invisible="1"/>
<div>
<div>
<field name="inventory_id" options="{'no_open': True}" class="mr8"/>
</div>
</div>
<label for="product_id"/>
<div colspan="2" class="oe_title">
<h1>
<field name="product_id" options="{'no_open': True}"/>
</h1>
</div>
<div colspan="2">
<field name="product_packagings"/>
</div>
<field name="lot_id"/>
<field name="package_id"/>
<label for="quantity_input" string="Quantity" class="ml32"/>
<div colspan="2" class="ml32">
<h1 class="bg-primary o_shuttle_highlight">
<field name="quantity_input"
default_focus="1"
class="oe_inline"/>
<field name="product_uom_id" options="{'no_open': True}" class="oe_inline ml8 text-white"/>
</h1>
</div>
</group>
</div>
</div>
<!-- on the right of the screen -->
<div class="o_shuttle_data_content o_shuttle_tray">
<group col="1">
<field name="tray_name"/>
<field name="tray_type_code"/>
<field name="tray_y"/>
<field name="tray_x"/>
</group>
<group>
<div>
<field name="tray_matrix"
widget="location_tray_matrix"
/>
</div>
</group>
</div>
</xpath>
</field>
</record>