Merge PR #797 into 13.0

Signed-off-by jgrandguillaume
This commit is contained in:
OCA-git-bot
2020-09-29 14:16:45 +00:00
103 changed files with 6101 additions and 2 deletions

View File

@@ -1,3 +1,5 @@
product-attribute
server-env
server-ux
web
wms

View File

@@ -0,0 +1 @@
../../../../stock_vertical_lift

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1 @@
../../../../stock_vertical_lift_kardex

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1 @@
../../../../stock_vertical_lift_packaging_type

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1 @@
../../../../stock_vertical_lift_server_env

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1 @@
../../../../stock_vertical_lift_storage_type

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -5,6 +5,7 @@
<field name="sequence">1</field>
<field name="location_id" ref="stock.stock_location_stock" />
<field name="company_id" ref="base.main_company" />
<field name="active" eval="False" />
</record>
<record id="stock_reserve_rule_removal_1_demo" model="stock.reserve.rule.removal">
<field name="rule_id" ref="stock_reserve_rule_1_demo" />

View File

@@ -25,8 +25,8 @@ A product: Funky Socks
Scenario:
* Activate Storage Locations and Multi-Warehouses
* You can open Inventory > Configuration > Stock Reservation Rules to see the
rules
* You can open Inventory > Configuration > Stock Reservation Rules to activate
and see the rules (by default in demo, the rules are created inactive)
* Open Transfer: Outgoing shipment (reservation rules demo 1)
* Check availability: it has 150 units, as it will not empty Zone A, it will not
take products there, it should take 100 in B and 50 in C (following the rules

View File

@@ -17,6 +17,8 @@ class TestReserveRule(common.SavepointCase):
"code": "WHTEST",
}
)
cls.rule = cls.env.ref("stock_reserve_rule.stock_reserve_rule_1_demo")
cls.rule.active = True
cls.customer_loc = cls.env.ref("stock.stock_location_customers")

View File

@@ -0,0 +1,134 @@
=============
Vertical Lift
=============
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
:alt: Alpha
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github
:target: https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_vertical_lift
:alt: OCA/stock-logistics-warehouse
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-12-0/stock-logistics-warehouse-12-0-stock_vertical_lift
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/153/12.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
Add configuration and dedicated screens to work with Vertical Lift
systems (such as Kardex Remstar, Modula, ...). Drivers for controlling
the lifts physically must be added by additional addons.
.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
`More details on development status <https://github.com/OCA/odoo-community.org/blob/master/website/Contribution/oca_module_lifecycle_development_status.rst>`_
**Table of contents**
.. contents::
:local:
Configuration
=============
General
~~~~~~~
In Inventory Settings, you must have:
* Storage Locations
* Multi-Warehouses
* Multi-Step Routes
Locations
~~~~~~~~~
Additional configuration parameters are added in Locations:
* Sub-locations of a location with the "Is a Vertical Lift View Location"
activated are considered as "Shuttles". A shuttle is a vertical lift shelf.
* Sub-locations of shuttles are considered as "Trays", which is a tier of a
shuttle. When a tray is created, a tray type must be selected. When saved, the
tray location will automatically create as many sub-locations - called
"Cells" - as the tray type contains.
* The tray type of a tray can be changed as long as none of its cell contains
products. When changed, it archives the cells and creates new ones as
configured on the new tray type.
Tray types
~~~~~~~~~~
Tray types can be configured in the Inventory settings.
A tray type defines how much cells a tray can hold. It is a square or rectangle
matrix of n cols * m rows.
Vertical Lift Shuttles
~~~~~~~~~~~~~~~~~~~~~~
The Shuttles are the Vertical Lift Trays. One Shuttle entity has to be created
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.
Known issues / Roadmap
======================
* Extract the tray types and matrix widget in a module, they can be used
alone without vertical lift
* Consider merging the 'vertical_lift_kind' with the kind added by
stock_location_zone
* Complete Pick screen and workflow (currently enough for a demo, not for production)
* Implement Put-away screen and workflow
* Implement Inventory screen and workflow
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/stock-logistics-warehouse/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_vertical_lift%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Camptocamp
Contributors
~~~~~~~~~~~~
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/stock-logistics-warehouse <https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_vertical_lift>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,41 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Vertical Lift",
"summary": "Provides the core for integration with Vertical Lifts",
"version": "13.0.1.0.0",
"category": "Stock",
"author": "Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": [
"stock",
"barcodes",
"base_sparse_field",
"stock_location_tray", # OCA/stock-logistics-warehouse
"web_notify", # OCA/web
"web_ir_actions_act_view_reload", # OCA/web
],
"website": "https://github.com/OCA/stock-logistics-warehouse",
"demo": [
"demo/stock_location_demo.xml",
"demo/vertical_lift_shuttle_demo.xml",
"demo/product_demo.xml",
"demo/stock_inventory_demo.xml",
"demo/stock_picking_demo.xml",
],
"data": [
"views/stock_location_views.xml",
"views/stock_move_line_views.xml",
"views/vertical_lift_shuttle_views.xml",
"views/vertical_lift_operation_base_views.xml",
"views/vertical_lift_operation_pick_views.xml",
"views/vertical_lift_operation_put_views.xml",
"views/vertical_lift_operation_inventory_views.xml",
"views/stock_vertical_lift_templates.xml",
"views/shuttle_screen_templates.xml",
"security/ir.model.access.csv",
"data/ir_sequence.xml",
],
"installable": True,
"development_status": "Alpha",
}

View File

@@ -0,0 +1 @@
from . import main

View File

@@ -0,0 +1,22 @@
import logging
import os
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class VerticalLiftController(http.Controller):
@http.route(["/vertical-lift"], type="http", auth="public", csrf=False)
def vertical_lift(self, answer, secret):
if secret == os.environ.get("VERTICAL_LIFT_SECRET", ""):
rec = request.env["vertical.lift.command"].sudo().record_answer(answer)
return str(rec.id)
else:
_logger.error(
"secret mismatch: %r != %r",
secret,
os.environ.get("VERTICAL_LIFT_SECRET", ""),
)
raise http.AuthenticationError()

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record model="ir.sequence" id="sequence_kardex_command">
<field name="name">Vertical Lift Commands</field>
<field name="code">vertical.lift.command</field>
<field name="prefix">L</field>
<field name="padding">6</field>
<field name="company_id" eval="False" />
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="product_running_socks" model="product.product">
<field name="default_code">RS200</field>
<field name="barcode">4491673293664</field>
<field name="name">Running Socks</field>
<field name="type">product</field>
<field name="categ_id" ref="product.product_category_6" />
<field name="lst_price">30.0</field>
<field name="standard_price">20.0</field>
<field name="weight">1.0</field>
<field name="tracking">none</field>
<field name="uom_id" ref="uom.product_uom_unit" />
<field name="uom_po_id" ref="uom.product_uom_unit" />
</record>
<record id="product_recovery_socks" model="product.product">
<field name="default_code">RS300</field>
<field name="barcode">2779891103531</field>
<field name="name">Recovery Socks</field>
<field name="type">product</field>
<field name="categ_id" ref="product.product_category_6" />
<field name="lst_price">30.0</field>
<field name="standard_price">20.0</field>
<field name="weight">1.0</field>
<field name="tracking">none</field>
<field name="uom_id" ref="uom.product_uom_unit" />
<field name="uom_po_id" ref="uom.product_uom_unit" />
</record>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="stock_inventory_vertical_lift_0" model="stock.inventory">
<field name="name">Starting Vertical Lift Inventory</field>
</record>
<record id="stock_inventory_vertical_lift_line_1" model="stock.inventory.line">
<field name="product_id" ref="product_running_socks" />
<field name="product_uom_id" ref="uom.product_uom_unit" />
<field name="inventory_id" ref="stock_inventory_vertical_lift_0" />
<field name="product_qty">30.0</field>
<field
name="location_id"
ref="stock_location_vertical_lift_demo_tray_1b_x3y2"
/>
</record>
<function model="stock.inventory" name="_action_start">
<function
eval="[[('state','=','draft'),('id', '=', ref('stock_vertical_lift.stock_inventory_vertical_lift_0'))]]"
model="stock.inventory"
name="search"
/>
</function>
<function model="stock.inventory" name="action_validate">
<function
eval="[[('state','=','confirm'),('id', '=', ref('stock_vertical_lift.stock_inventory_vertical_lift_0'))]]"
model="stock.inventory"
name="search"
/>
</function>
</odoo>

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="stock_location_vertical_lift" model="stock.location">
<field name="name">Vertical Lift</field>
<!-- Beware, must not be view, even if we must not have stock here.
Because stock moves targeting views are not included in the
inventory at date, and we want to be able to use "Vertical Lift View"
as destination (the final destination will be a "cell" in a Shuttle).
-->
<field name="location_id" ref="stock.stock_location_stock" />
<field name="usage">internal</field>
<field name="vertical_lift_location" eval="True" />
</record>
<record id="stock_location_vertical_lift_demo_shuttle_1" model="stock.location">
<field name="name">Shuttle 1</field>
<field name="location_id" ref="stock_location_vertical_lift" />
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_1a" model="stock.location">
<field name="name">Tray 1A</field>
<field name="barcode">T1A</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_1" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_small_8x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_1b" model="stock.location">
<field name="name">Tray 1B</field>
<field name="barcode">T1B</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_1" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_large_16x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_1c" model="stock.location">
<field name="name">Tray 1C</field>
<field name="barcode">T1C</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_1" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_large_32x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_shuttle_2" model="stock.location">
<field name="name">Shuttle 2</field>
<field name="location_id" ref="stock_location_vertical_lift" />
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_2a" model="stock.location">
<field name="name">Tray 2A</field>
<field name="barcode">T2A</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_2" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_large_8x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_2b" model="stock.location">
<field name="name">Tray 2B</field>
<field name="barcode">T2B</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_2" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_large_4x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_2c" model="stock.location">
<field name="name">Tray 2C</field>
<field name="barcode">T2C</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_2" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_large_16x_2"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_2d" model="stock.location">
<field name="name">Tray 2D</field>
<field name="barcode">T2D</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_2" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_small_8x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_shuttle_3" model="stock.location">
<field name="name">Shuttle 3</field>
<field name="location_id" ref="stock_location_vertical_lift" />
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_3a" model="stock.location">
<field name="name">Tray 3A</field>
<field name="barcode">T3A</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_3" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_small_32x"
/>
<field name="usage">internal</field>
</record>
<record id="stock_location_vertical_lift_demo_tray_3b" model="stock.location">
<field name="name">Tray 3B</field>
<field name="barcode">T3B</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_3" />
<field
name="tray_type_id"
ref="stock_location_tray.stock_location_tray_type_large_32x"
/>
<field name="usage">internal</field>
</record>
<!-- When the trays are created, they will create their 'cell' locations.
This method will add xmlids on them to be able to reference them in
other demo data and tests.
-->
<function model="stock.location" name="_create_tray_xmlids">
<function
eval="[[('cell_in_tray_type_id', '!=', False)]]"
model="stock.location"
name="search"
/>
<value>stock_vertical_lift</value>
</function>
</odoo>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="stock_picking_out_demo_vertical_lift_1" model="stock.picking">
<field name="picking_type_id" ref="stock.picking_type_out" />
<field name="origin">Outgoing shipment from Vertical Lift (demo)</field>
<field name="partner_id" ref="base.res_partner_1" />
<field name="date" eval="DateTime.today()" />
<field name="location_id" ref="stock.stock_location_stock" />
<field name="location_dest_id" ref="stock.stock_location_customers" />
<field
name="move_lines"
model="stock.move"
eval="[(0, 0, {
'name': obj().env.ref('stock_vertical_lift.product_running_socks').name,
'product_id': ref('stock_vertical_lift.product_running_socks'),
'product_uom': ref('uom.product_uom_unit'),
'product_uom_qty': 15.0,
'picking_type_id': ref('stock.picking_type_out'),
'location_id': ref('stock.stock_location_stock'),
'location_dest_id': ref('stock.stock_location_customers'),
})]"
/>
</record>
<function model="stock.picking" name="action_confirm">
<value
model="stock.picking"
eval="[obj().env.ref('stock_vertical_lift.stock_picking_out_demo_vertical_lift_1').id]"
/>
</function>
<function model="stock.picking" name="action_assign">
<value
model="stock.picking"
eval="[obj().env.ref('stock_vertical_lift.stock_picking_out_demo_vertical_lift_1').id]"
/>
</function>
<record id="stock_picking_in_demo_vertical_lift_1" model="stock.picking">
<field name="picking_type_id" ref="stock.picking_type_in" />
<field name="origin">Incoming shipment from Vertical Lift (demo)</field>
<field name="partner_id" ref="base.res_partner_1" />
<field name="date" eval="DateTime.today()" />
<field name="location_id" ref="stock.stock_location_suppliers" />
<field name="location_dest_id" ref="stock.stock_location_stock" />
<field
name="move_lines"
model="stock.move"
eval="[(0, 0, {
'name': obj().env.ref('stock_vertical_lift.product_running_socks').name,
'product_id': ref('stock_vertical_lift.product_running_socks'),
'product_uom': ref('uom.product_uom_unit'),
'product_uom_qty': 15.0,
'picking_type_id': ref('stock.picking_type_in'),
'location_id': ref('stock.stock_location_suppliers'),
'location_dest_id': ref('stock.stock_location_stock'),
})]"
/>
</record>
<function model="stock.picking" name="action_confirm">
<value
model="stock.picking"
eval="[obj().env.ref('stock_vertical_lift.stock_picking_in_demo_vertical_lift_1').id]"
/>
</function>
</odoo>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="stock_vertical_lift_demo_shuttle_1" model="vertical.lift.shuttle">
<field name="name">Shuttle 1</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_1" />
<field name="mode">pick</field>
</record>
<record id="stock_vertical_lift_demo_shuttle_2" model="vertical.lift.shuttle">
<field name="name">Shuttle 2</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_2" />
<field name="mode">pick</field>
</record>
<record id="stock_vertical_lift_demo_shuttle_3" model="vertical.lift.shuttle">
<field name="name">Shuttle 3</field>
<field name="location_id" ref="stock_location_vertical_lift_demo_shuttle_3" />
<field name="mode">pick</field>
</record>
</odoo>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" xmlns:xlink="http://www.w3.org/1999/xlink" width="356" height="81.587" viewBox="0 0 356 81.587">
<desc></desc>
<rect x="0" y="0" width="356" height="81.587" style="fill:#ffffff;" />
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="2" y1="0" x2="2" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="7" y1="0" x2="7" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="13" y1="0" x2="13" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="23" y1="0" x2="23" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="33" y1="0" x2="33" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="40" y1="0" x2="40" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="45" y1="0" x2="45" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="52" y1="0" x2="52" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="59" y1="0" x2="59" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="67" y1="0" x2="67" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="75" y1="0" x2="75" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="80" y1="0" x2="80" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="90" y1="0" x2="90" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="97" y1="0" x2="97" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="107" y1="0" x2="107" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="111" y1="0" x2="111" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="117" y1="0" x2="117" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="128" y1="0" x2="128" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="133" y1="0" x2="133" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="140" y1="0" x2="140" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="149" y1="0" x2="149" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="155" y1="0" x2="155" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="161" y1="0" x2="161" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:8;" x1="170" y1="0" x2="170" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="177" y1="0" x2="177" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="182" y1="0" x2="182" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="189" y1="0" x2="189" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="200" y1="0" x2="200" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="207" y1="0" x2="207" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="211" y1="0" x2="211" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="221" y1="0" x2="221" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="226" y1="0" x2="226" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="233" y1="0" x2="233" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="243" y1="0" x2="243" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="249" y1="0" x2="249" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="254" y1="0" x2="254" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="265" y1="0" x2="265" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:8;" x1="272" y1="0" x2="272" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="281" y1="0" x2="281" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="287" y1="0" x2="287" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="292" y1="0" x2="292" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="299" y1="0" x2="299" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="309" y1="0" x2="309" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="318" y1="0" x2="318" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="327" y1="0" x2="327" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="332" y1="0" x2="332" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="343" y1="0" x2="343" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="349" y1="0" x2="349" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="354" y1="0" x2="354" y2="60"/>
<text x="178" y="77.271" text-anchor="middle" style="font-family: Tahoma; font-size: 20px; stroke: none; fill:#000000;">O-BTN.release</text></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" xmlns:xlink="http://www.w3.org/1999/xlink" width="290" height="81.587" viewBox="0 0 290 81.587">
<desc></desc>
<rect x="0" y="0" width="290" height="81.587" style="fill:#ffffff;" />
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="2" y1="0" x2="2" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="7" y1="0" x2="7" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="13" y1="0" x2="13" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="23" y1="0" x2="23" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="33" y1="0" x2="33" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="40" y1="0" x2="40" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="45" y1="0" x2="45" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="52" y1="0" x2="52" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="59" y1="0" x2="59" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="67" y1="0" x2="67" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="75" y1="0" x2="75" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="80" y1="0" x2="80" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="90" y1="0" x2="90" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="97" y1="0" x2="97" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="107" y1="0" x2="107" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="111" y1="0" x2="111" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="117" y1="0" x2="117" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="128" y1="0" x2="128" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="133" y1="0" x2="133" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="140" y1="0" x2="140" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="149" y1="0" x2="149" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="155" y1="0" x2="155" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:8;" x1="162" y1="0" x2="162" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="171" y1="0" x2="171" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="177" y1="0" x2="177" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="183" y1="0" x2="183" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="188" y1="0" x2="188" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:8;" x1="202" y1="0" x2="202" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="209" y1="0" x2="209" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="215" y1="0" x2="215" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="221" y1="0" x2="221" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="226" y1="0" x2="226" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="233" y1="0" x2="233" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="243" y1="0" x2="243" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="248" y1="0" x2="248" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="259" y1="0" x2="259" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="266" y1="0" x2="266" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:6;" x1="277" y1="0" x2="277" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:2;" x1="283" y1="0" x2="283" y2="60"/>
<line fill="none" style="stroke:#000000; stroke-width:4;" x1="288" y1="0" x2="288" y2="60"/>
<text x="145" y="77.271" text-anchor="middle" style="font-family: Tahoma; font-size: 20px; stroke: none; fill:#000000;">O-BTN.save</text></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,11 @@
from . import vertical_lift_shuttle
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
from . import stock_quant
from . import vertical_lift_command

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

@@ -0,0 +1,159 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, exceptions, fields, models
class StockLocation(models.Model):
_inherit = "stock.location"
vertical_lift_location = fields.Boolean(
"Is a Vertical Lift View Location?",
default=False,
help="Check this box to use it as the view for Vertical" " Lift Shuttles.",
)
vertical_lift_kind = fields.Selection(
selection=[
("view", "View"),
("shuttle", "Shuttle"),
("tray", "Tray"),
("cell", "Cell"),
],
compute="_compute_vertical_lift_kind",
store=True,
)
# This is a one2one in practice, but this one is not really interesting.
# It's there only to be in the depends of 'vertical_lift_shuttle_id', which
# give the unique shuttle for any location in the tree (whether it's a
# shuttle, a tray or a cell)
inverse_vertical_lift_shuttle_ids = fields.One2many(
comodel_name="vertical.lift.shuttle", inverse_name="location_id", readonly=True
)
# compute the unique shuttle for any shuttle, tray or cell location, by
# going through the parents
vertical_lift_shuttle_id = fields.Many2one(
comodel_name="vertical.lift.shuttle",
compute="_compute_vertical_lift_shuttle_id",
store=True,
)
@api.depends(
"location_id", "location_id.vertical_lift_kind", "vertical_lift_location"
)
def _compute_vertical_lift_kind(self):
tree = {"view": "shuttle", "shuttle": "tray", "tray": "cell"}
for location in self:
if location.vertical_lift_location:
location.vertical_lift_kind = "view"
continue
kind = tree.get(location.location_id.vertical_lift_kind, False)
location.vertical_lift_kind = kind
@api.depends(
"inverse_vertical_lift_shuttle_ids", "location_id.vertical_lift_shuttle_id"
)
def _compute_vertical_lift_shuttle_id(self):
for location in self:
if location.inverse_vertical_lift_shuttle_ids:
# we have a unique constraint on the other side
assert len(location.inverse_vertical_lift_shuttle_ids) == 1
shuttle = location.inverse_vertical_lift_shuttle_ids
else:
shuttle = location.location_id.vertical_lift_shuttle_id
location.vertical_lift_shuttle_id = shuttle
def _hardware_vertical_lift_fetch_tray(self, cell_location=None):
payload = self._hardware_vertical_lift_fetch_tray_payload(cell_location)
return self.vertical_lift_shuttle_id._hardware_send_message(payload)
def _hardware_vertical_lift_fetch_tray_payload(self, cell_location=None):
"""Prepare "fetch" message to be sent to the vertical lift hardware
Private method, this is where the implementation actually happens.
Addons can add their instructions based on the hardware used for
this location.
The hardware used for a location can be found in:
``self.vertical_lift_shuttle_id.hardware``
Each addon can implement its own mechanism depending of this value
and must call ``super``.
The method must send the command to the vertical lift to fetch / open
the tray. If a ``cell_location`` is passed and if the hardware supports
a way to show a cell (such as a laser pointer), it should send this
command as well.
Useful information that could be needed for the drivers:
* Any field of `self` (name, barcode, ...) which is the current tray.
* Any field of `cell_location` (name, barcode, ...) which is the cell
in the tray.
* ``self.vertical_lift_shuttle_id`` is the current Shuttle, where we
find details about the hardware, the current mode (pick, put, ...).
* ``self.tray_type_id`` is the kind of tray.
* ``self.tray_type_id.width_per_cell`` and
``self.tray_type_id.depth_per_cell`` return the size of a cell in mm.
* ``cell_location.posx`` and ``posy`` are the coordinate from the
bottom-left of the tray.
* ``cell_location.tray_cell_center_position()`` returns the central
position of the cell in mm from the bottom-left of a tray. (distance
from left, distance from bottom). Can be used for instance for
highlighting the cell using a laser pointer.
Returns a message in bytes, that will be sent through
``VerticalLiftShuttle._hardware_send_message()``.
"""
if self.vertical_lift_shuttle_id.hardware == "simulation":
message = _("Opening tray {}.").format(self.name)
if cell_location:
from_left, from_bottom = cell_location.tray_cell_center_position()
message += _("<br/>Laser pointer on x{} y{} ({}mm, {}mm)").format(
cell_location.posx, cell_location.posy, from_left, from_bottom
)
return message.encode("utf-8")
else:
raise NotImplementedError()
def fetch_vertical_lift_tray(self, cell_location=None):
"""Send instructions to the vertical lift hardware to fetch a tray
Public method to use for:
* fetch the vertical lift tray and presenting it to the operator
(physically)
* direct the laser pointer to the cell's location if set
Depending on the hardware, the laser pointer may not be implemented.
The actual implementation of the method goes in the private method
``_hardware_vertical_lift_fetch_tray()``.
"""
self.ensure_one()
if self.vertical_lift_kind == "cell":
if cell_location:
raise ValueError(
"cell_location cannot be set when the location is a cell."
)
tray = self.location_id
tray.fetch_vertical_lift_tray(cell_location=self)
elif self.vertical_lift_kind == "tray":
self._hardware_vertical_lift_fetch_tray(cell_location=cell_location)
else:
raise exceptions.UserError(
_("Cannot fetch a vertical lift tray on location %s") % (self.name,)
)
return True
def button_fetch_vertical_lift_tray(self):
self.ensure_one()
if self.vertical_lift_kind in ("cell", "tray"):
self.fetch_vertical_lift_tray()
return True
def button_release_vertical_lift_tray(self):
self.ensure_one()
if self.vertical_lift_kind:
self.vertical_lift_shuttle_id.release_vertical_lift_tray()
return True

View File

@@ -0,0 +1,22 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class StockMove(models.Model):
_inherit = "stock.move"
def write(self, vals):
result = super().write(vals)
if "state" in vals:
# We cannot have fields to depends on to invalidate these computed
# fields on vertical.lift.operation.*. But we know that when the
# state of any move line changes, we can invalidate them as the
# count of assigned move lines may change (and we track this in
# stock.move, not stock.move.line, because the state of the lines
# is a related to this one).
models = ("vertical.lift.operation.pick", "vertical.lift.operation.put")
for model in models:
self.env[model].invalidate_cache(["number_of_ops", "number_of_ops_all"])
return result

View File

@@ -0,0 +1,21 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
def fetch_vertical_lift_tray_source(self):
self.ensure_one()
self.location_id.fetch_vertical_lift_tray()
# We reload mainly because otherwise, it would close
# the popup. This action is provided by the OCA module
# web_ir_actions_act_view_reload
return {"type": "ir.actions.act_view_reload"}
def fetch_vertical_lift_tray_dest(self):
self.ensure_one()
self.location_dest_id.fetch_vertical_lift_tray()
return {"type": "ir.actions.act_view_reload"}

View File

@@ -0,0 +1,18 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class StockQuant(models.Model):
_inherit = "stock.quant"
def _update_available_quantity(self, *args, **kwargs):
result = super()._update_available_quantity(*args, **kwargs)
# We cannot have fields to depends on to invalidate this computed
# fields on vertical.lift.operation.* models. But we know that when the
# quantity of quant changes, we can invalidate the field
models = ("vertical.lift.operation.pick", "vertical.lift.operation.put")
for model in models:
self.env[model].invalidate_cache(["tray_qty"])
return result

View File

@@ -0,0 +1,50 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, exceptions, fields, models
_logger = logging.getLogger(__name__)
class VerticalLiftCommand(models.Model):
_name = "vertical.lift.command"
_order = "shuttle_id, name desc"
_description = "commands sent to the shuttle"
def _default_name(self):
return self.env["ir.sequence"].next_by_code("vertical.lift.command")
name = fields.Char(
"Name", default=lambda s: s._default_name(), required=True, index=True
)
command = fields.Char(required=True)
answer = fields.Char()
error = fields.Char()
shuttle_id = fields.Many2one("vertical.lift.shuttle", required=True)
def record_answer(self, answer):
name = self._get_key(answer)
record = self.search([("name", "=", name)], limit=1)
if not record:
_logger.error("unable to match answer to a command: %r", answer)
raise exceptions.UserError(_("Unknown record %s") % name)
record.answer = answer
record.shuttle_id._hardware_response_callback(record)
return record
def _get_key(self, answer):
key = answer.split("|")[1:2]
if key:
return key[0]
else:
return ""
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if "name" not in values:
name = self._get_key(values.get("command"))
if name:
values["name"] = name
return super().create(vals_list)

View File

@@ -0,0 +1,427 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
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)"""
_name = "vertical.lift.operation.base"
_inherit = "barcodes.barcode_events_mixin"
_description = "Vertical Lift Operation - Base"
name = fields.Char(related="shuttle_id.name", readonly=True)
shuttle_id = fields.Many2one(
comodel_name="vertical.lift.shuttle", required=True, readonly=True
)
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)
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 = [
(
"shuttle_id_unique",
"UNIQUE(shuttle_id)",
"One pick can be run at a time for a shuttle.",
)
]
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
# onchange, which is really annoying when we want it to act as a
# 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"])
# We can't know which fields on_barcode_scanned changed, refresh
# everything.
return {"value": self.read()[0]}
else:
return super().onchange(values, field_name, field_onchange)
@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 action_open_screen(self):
return self.shuttle_id.action_open_screen()
def action_menu(self):
return self.shuttle_id.action_menu()
def action_manual_barcode(self):
return self.shuttle_id.action_manual_barcode()
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"""
self.ensure_one()
if not self.step() == "release":
return
self.next_step()
def _render_product_packagings(self, product):
if not product:
return ""
return self.env["ir.qweb"].render(
"stock_vertical_lift.packagings",
self._prepare_values_for_product_packaging(product),
)
def _prepare_values_for_product_packaging(self, product):
return {"product": product}
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"))
def _rainbow_man(self, message=None):
if not message:
message = _("Congrats, you cleared the queue!")
return {
"effect": {
"fadeout": "slow",
"message": message,
"img_url": "/web/static/src/img/smile.svg",
"type": "rainbow_man",
}
}
def _send_notification_refresh(self):
"""Send a refresh notification
Generally, you want to call the method
_send_notification_refresh() on VerticalLiftShuttle so you
don't need to know the id of the current operation.
Other notifications can be implemented, they have to be
added in static/src/js/vertical_lift.js and the message
must contain an "action" and "params".
"""
self.ensure_one()
channel = "notify_vertical_lift_screen"
bus_message = {
"action": "refresh",
"params": {"model": self._name, "id": self.id},
}
self.env["bus.bus"].sendone(channel, bus_message)
class VerticalLiftOperationTransfer(models.AbstractModel):
"""Base model for shuttle pick and put operations"""
_name = "vertical.lift.operation.transfer"
_inherit = "vertical.lift.operation.base"
_description = "Vertical Lift Operation - Transfer"
current_move_line_id = fields.Many2one(
comodel_name="stock.move.line", readonly=True
)
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
picking_id = fields.Many2one(
related="current_move_line_id.picking_id", readonly=True
)
picking_origin = fields.Char(
related="current_move_line_id.picking_id.origin", readonly=True
)
picking_partner_id = fields.Many2one(
related="current_move_line_id.picking_id.partner_id", readonly=True
)
product_id = fields.Many2one(
related="current_move_line_id.product_id", readonly=True
)
product_uom_id = fields.Many2one(
related="current_move_line_id.product_uom_id", readonly=True
)
product_uom_qty = fields.Float(
related="current_move_line_id.product_uom_qty", readonly=True
)
product_packagings = fields.Html(
string="Packaging", compute="_compute_product_packagings"
)
qty_done = fields.Float(related="current_move_line_id.qty_done", readonly=True)
lot_id = fields.Many2one(related="current_move_line_id.lot_id", readonly=True)
location_dest_id = fields.Many2one(
string="Destination",
related="current_move_line_id.location_dest_id",
readonly=False,
)
# TODO add a glue addon with product_expiry to add the field
def on_barcode_scanned(self, barcode):
self.ensure_one()
self.env.user.notify_info(
"Scanned barcode: {}. Not implemented.".format(barcode)
)
@api.depends("current_move_line_id.product_id.packaging_ids")
def _compute_product_packagings(self):
for record in self:
product = record.current_move_line_id.product_id
if not product:
record.product_packagings = ""
continue
content = self._render_product_packagings(product)
record.product_packagings = content
@api.depends()
def _compute_number_of_ops(self):
for record in self:
record.number_of_ops = record.count_move_lines_to_do()
@api.depends()
def _compute_number_of_ops_all(self):
for record in self:
record.number_of_ops_all = record.count_move_lines_to_do_all()
@api.depends("tray_location_id", "current_move_line_id.product_id")
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.0
continue
product = record.current_move_line_id.product_id
location = record.tray_location_id
record.tray_qty = self._get_tray_qty(product, location)
@api.depends("current_move_line_id")
def _compute_tray_data(self):
for record in self:
modes = {"pick": "location_id", "put": "location_dest_id"}
location = record.current_move_line_id[modes[record.mode]]
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
def _domain_move_lines_to_do(self):
# to implement in sub-classes
return [("id", "=", 0)]
def _domain_move_lines_to_do_all(self):
# to implement in sub-classes
return [("id", "=", 0)]
def count_move_lines_to_do(self):
"""Count move lines to process in current shuttles"""
self.ensure_one()
return self.env["stock.move.line"].search_count(self._domain_move_lines_to_do())
def count_move_lines_to_do_all(self):
"""Count move lines to process in all shuttles"""
self.ensure_one()
return self.env["stock.move.line"].search_count(
self._domain_move_lines_to_do_all()
)
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()
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

@@ -0,0 +1,266 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
from odoo.tools import float_compare
from odoo.addons.base_sparse_field.models.fields import Serialized
# 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"
_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
)
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()
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:
product = record.current_inventory_line_id.product_id
if not product:
record.product_packagings = ""
continue
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.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 reset_steps(self):
self.clear_current_inventory_line()
super().reset_steps()
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 _start_confirm_wrong_quantity(self):
self.last_quantity_input = self.quantity_input
self.quantity_input = 0.0
return True
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
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",
)
self.current_inventory_line_id = next_line
if next_line:
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":
# close the tray once everything is inventoried
self.shuttle_id.release_vertical_lift_tray()
# sorry not sorry
return self._rainbow_man()

View File

@@ -0,0 +1,90 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, models
class VerticalLiftOperationPick(models.Model):
_name = "vertical.lift.operation.pick"
_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.next_step()
else:
self.env.user.notify_warning(
_("No location found for barcode {}").format(barcode)
)
def _domain_move_lines_to_do(self):
domain = [
("state", "in", ("assigned", "partially_available")),
("location_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_id", "child_of", shuttle_locations.ids),
]
return domain
def fetch_tray(self):
self.current_move_line_id.fetch_vertical_lift_tray_source()
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
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":
# we don't need to release (close) the tray until we have reached
# the last line: the release is implicit when a next line is
# fetched
self.shuttle_id.release_vertical_lift_tray()
# sorry not sorry
return self._rainbow_man()

View File

@@ -0,0 +1,181 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, fields, models
from odoo.osv.expression import AND
class VerticalLiftOperationPut(models.Model):
_name = "vertical.lift.operation.put"
_inherit = "vertical.lift.operation.transfer"
_description = "Vertical Lift Operation Put"
_initial_state = "scan_source"
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 (
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 _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()
if self.step() == "scan_source":
self._scan_source_action(barcode)
elif self.step() in ("scan_tray_type", "save"):
# note: we must be able to scan a different tray type when we are
# in the save step too, in case we couldn't put it in the first one
# for some reason.
self._scan_tray_type_action(barcode)
def _scan_source_action(self, barcode):
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 _scan_tray_type_action(self, barcode):
tray_type = self._find_tray_type(barcode)
if tray_type:
if self._assign_available_cell(tray_type):
self.fetch_tray()
if self.step() == "scan_tray_type":
# when we are in "save" step, stay here
self.next_step()
else:
self.env.user.notify_warning(
_('No free space for tray type "{}" in this shuttle.').format(
tray_type.display_name
)
)
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", "in", package.ids)]]
)
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 _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
self.current_move_line_id.package_level_id.location_dest_id = location
return True
return False
def fetch_tray(self):
self.current_move_line_id.fetch_vertical_lift_tray_dest()
def button_release(self):
# release (close) the tray each time, because for put-away, we
# never know if the operator will scan another line or not
self.shuttle_id.release_vertical_lift_tray()
super().button_release()
if self.count_move_lines_to_do_all() == 0:
# sorry not sorry
return self._rainbow_man()

View File

@@ -0,0 +1,277 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import socket
import ssl
from odoo import _, fields, models
_logger = logging.getLogger(__name__)
class VerticalLiftShuttle(models.Model):
_name = "vertical.lift.shuttle"
_inherit = "barcodes.barcode_events_mixin"
_description = "Vertical Lift Shuttle"
name = fields.Char()
mode = fields.Selection(
[("pick", "Pick"), ("put", "Put"), ("inventory", "Inventory")],
default="pick",
required=True,
)
location_id = fields.Many2one(
comodel_name="stock.location",
required=True,
domain="[('vertical_lift_kind', '=', 'shuttle')]",
ondelete="restrict",
help="The Shuttle source location for Pick operations "
"and destination location for Put operations.",
)
hardware = fields.Selection(
selection="_selection_hardware", default="simulation", required=True
)
server = fields.Char(help="hostname or IP address of the server")
port = fields.Integer(
help="network port of the server on which to send the message"
)
use_tls = fields.Boolean(
help="set this if the server expects TLS wrapped communication"
)
command_ids = fields.One2many(
"vertical.lift.command", "shuttle_id", string="Hardware commands"
)
_sql_constraints = [
(
"location_id_unique",
"UNIQUE(location_id)",
"You cannot have two shuttles using the same location.",
)
]
def _selection_hardware(self):
return [("simulation", "Simulation")]
@property
def _model_for_mode(self):
return {
"pick": "vertical.lift.operation.pick",
"put": "vertical.lift.operation.put",
"inventory": "vertical.lift.operation.inventory",
}
@property
def _screen_view_for_mode(self):
return {
"pick": ("stock_vertical_lift." "vertical_lift_operation_pick_screen_view"),
"put": ("stock_vertical_lift." "vertical_lift_operation_put_screen_view"),
"inventory": (
"stock_vertical_lift." "vertical_lift_operation_inventory_screen_view"
),
}
def _hardware_send_message(self, payload):
"""default implementation for message sending
If in hardware is 'simulation' then display a simple message.
Otherwise defaults to connecting to server:port using a TCP socket
(optionnally wrapped with TLS) and sending the payload.
:param payload: a bytes object containing the payload
"""
self.ensure_one()
_logger.info("send %r", payload)
command_values = {"shuttle_id": self.id, "command": payload.decode()}
self.env["vertical.lift.command"].sudo().create(command_values)
if self.hardware == "simulation":
self.env.user.notify_info(message=payload, title=_("Lift Simulation"))
return True
else:
conn = self._hardware_get_server_connection()
try:
offset = 0
while True:
size = conn.send(payload[offset:])
offset += size
if offset >= len(payload) or not size:
break
finally:
self._hardware_release_server_connection(conn)
def _hardware_response_callback(self, command):
"""should be called when a response is received from the hardware
:param response: a string
"""
success = self._check_server_response(command)
self._send_notification_refresh(success)
def _check_server_response(self, command):
"""Use this to check if the response is a success or a failure
:param payload: the payload sent
:param response: the response received
:return: True if the response is a succes, False otherwise
"""
return True
def _hardware_release_server_connection(self, conn):
conn.close()
def _hardware_get_server_connection(self):
"""This implementation will yield a new connection to the server
and close() it when exiting the context.
Override to match the communication protocol of your hardware"""
conn = socket.create_connection((self.server, self.port))
if self.use_tls:
ctx = ssl.create_default_context()
self._hardware_update_tls_context(ctx)
conntls = ctx.wrap_socket(conn, server_hostname=self.server)
return conntls
else:
return conn
def _hardware_update_tls_context(self, context):
"""Update the TLS context, e.g. to add a client certificate.
This method does nothing, override to match your communication
protocol."""
pass
def _operation_for_mode(self):
model = self._model_for_mode[self.mode]
record = self.env[model].search([("shuttle_id", "=", self.id)])
if not record:
record = self.env[model].create({"shuttle_id": self.id})
return record
def action_open_screen(self):
self.ensure_one()
assert self.mode in ("pick", "put", "inventory")
screen_xmlid = self._screen_view_for_mode[self.mode]
operation = self._operation_for_mode()
operation.on_screen_open()
return {
"type": "ir.actions.act_window",
"res_model": operation._name,
"views": [[self.env.ref(screen_xmlid).id, "form"]],
"res_id": operation.id,
"target": "fullscreen",
"flags": {
"withControlPanel": False,
"form_view_initial_mode": "edit",
"no_breadcrumbs": True,
},
}
def action_menu(self):
menu_xmlid = "stock_vertical_lift.vertical_lift_shuttle_form_menu"
return {
"type": "ir.actions.act_window",
"res_model": "vertical.lift.shuttle",
"views": [[self.env.ref(menu_xmlid).id, "form"]],
"name": _("Menu"),
"target": "new",
"res_id": self.id,
}
def action_back_to_settings(self):
self.release_vertical_lift_tray()
action_xmlid = "stock_vertical_lift.vertical_lift_shuttle_action"
action = self.env.ref(action_xmlid).read()[0]
action["target"] = "main"
return action
def action_manual_barcode(self):
return {
"type": "ir.actions.act_window",
"res_model": "vertical.lift.shuttle.manual.barcode",
"view_mode": "form",
"name": _("Barcode"),
"target": "new",
}
# TODO: should the mode be changed on all the shuttles at the same time?
def switch_pick(self):
self.mode = "pick"
self.release_vertical_lift_tray()
return self.action_open_screen()
def switch_put(self):
self.mode = "put"
self.release_vertical_lift_tray()
return self.action_open_screen()
def switch_inventory(self):
self.mode = "inventory"
self.release_vertical_lift_tray()
return self.action_open_screen()
def _hardware_vertical_lift_release_tray_payload(self):
"""Prepare "release" message to be sent to the vertical lift hardware
Private method, this is where the implementation actually happens.
Addons can add their instructions based on the hardware used for
this location.
The hardware used for a location can be found in:
``self.hardware``
Each addon can implement its own mechanism depending of this value
and must call ``super``.
The method must send the command to the vertical lift to release (close)
the tray.
Returns a message in bytes, that will be sent through
``VerticalLiftShuttle._hardware_send_message()``.
"""
if self.hardware == "simulation":
message = _("Releasing tray")
return message.encode("utf-8")
else:
raise NotImplementedError()
def release_vertical_lift_tray(self):
"""Send instructions to the vertical lift hardware to close trays
The actual implementation of the method goes in the private method
``_hardware_vertical_lift_release_tray()``.
"""
self.ensure_one()
payload = self._hardware_vertical_lift_release_tray_payload()
return self._hardware_send_message(payload)
def _send_notification_refresh(self, success):
"""Send a refresh notification to the current opened screen
The form controller on the front-end side will instantaneously
refresh the form with the latest committed data.
It can be used for instance after a vertical lift hardware
event occurred to inform the user on their screen.
The method is private only to prevent xml/rpc calls to
interact with the screen.
"""
# XXX do we want to do something special in the notification?
self._operation_for_mode()._send_notification_refresh()
class VerticalLiftShuttleManualBarcode(models.TransientModel):
_name = "vertical.lift.shuttle.manual.barcode"
_description = "Action to input a barcode"
barcode = fields.Char(string="Barcode")
def button_save(self):
active_id = self.env.context.get("active_id")
model = self.env.context.get("active_model")
record = self.env[model].browse(active_id).exists()
if not record:
return
if self.barcode:
record.on_barcode_scanned(self.barcode)

View File

@@ -0,0 +1,48 @@
General
~~~~~~~
In Inventory Settings, you must have:
* Storage Locations
* Multi-Warehouses
* Multi-Step Routes
Locations
~~~~~~~~~
Additional configuration parameters are added in Locations:
* Sub-locations of a location with the "Is a Vertical Lift View Location"
activated are considered as "Shuttles". A shuttle is a vertical lift shelf.
* Sub-locations of shuttles are considered as "Trays", which is a tier of a
shuttle. When a tray is created, a tray type must be selected. When saved, the
tray location will automatically create as many sub-locations - called
"Cells" - as the tray type contains.
* The tray type of a tray can be changed as long as none of its cell contains
products. When changed, it archives the cells and creates new ones as
configured on the new tray type.
Tray types
~~~~~~~~~~
Tray types can be configured in the Inventory settings.
A tray type defines how much cells a tray can hold. It is a square or rectangle
matrix of n cols * m rows.
Vertical Lift Shuttles
~~~~~~~~~~~~~~~~~~~~~~
The Shuttles are the Vertical Lift Trays. One Shuttle entity has to be created
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

@@ -0,0 +1 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>

View File

@@ -0,0 +1,3 @@
Add configuration and dedicated screens to work with Vertical Lift
systems (such as Kardex Remstar, Modula, ...). Drivers for controlling
the lifts physically must be added by additional addons.

View File

@@ -0,0 +1,8 @@
* 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

@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_vertical_lift_shuttle_stock_user,access_vertical_lift_shuttle stock user,model_vertical_lift_shuttle,stock.group_stock_user,1,0,0,0
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_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
2 access_vertical_lift_shuttle_stock_user access_vertical_lift_shuttle stock user model_vertical_lift_shuttle stock.group_stock_user 1 0 0 0
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
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

@@ -0,0 +1,490 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.14: http://docutils.sourceforge.net/" />
<title>Vertical Lift</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="vertical-lift">
<h1 class="title">Vertical Lift</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_vertical_lift"><img alt="OCA/stock-logistics-warehouse" src="https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/stock-logistics-warehouse-12-0/stock-logistics-warehouse-12-0-stock_vertical_lift"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/153/12.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>Add configuration and dedicated screens to work with Vertical Lift
systems (such as Kardex Remstar, Modula, …). Drivers for controlling
the lifts physically must be added by additional addons.</p>
<div class="admonition important">
<p class="first admonition-title">Important</p>
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
<a class="reference external" href="https://github.com/OCA/odoo-community.org/blob/master/website/Contribution/oca_module_lifecycle_development_status.rst">More details on development status</a></p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a><ul>
<li><a class="reference internal" href="#general" id="id2">General</a></li>
<li><a class="reference internal" href="#locations" id="id3">Locations</a></li>
<li><a class="reference internal" href="#tray-types" id="id4">Tray types</a></li>
<li><a class="reference internal" href="#vertical-lift-shuttles" id="id5">Vertical Lift Shuttles</a></li>
</ul>
</li>
<li><a class="reference internal" href="#known-issues-roadmap" id="id6">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id7">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id8">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id9">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id10">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id11">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id1">Configuration</a></h1>
<div class="section" id="general">
<h2><a class="toc-backref" href="#id2">General</a></h2>
<p>In Inventory Settings, you must have:</p>
<blockquote>
<ul class="simple">
<li>Storage Locations</li>
<li>Multi-Warehouses</li>
<li>Multi-Step Routes</li>
</ul>
</blockquote>
</div>
<div class="section" id="locations">
<h2><a class="toc-backref" href="#id3">Locations</a></h2>
<p>Additional configuration parameters are added in Locations:</p>
<ul class="simple">
<li>Sub-locations of a location with the “Is a Vertical Lift View Location”
activated are considered as “Shuttles”. A shuttle is a vertical lift shelf.</li>
<li>Sub-locations of shuttles are considered as “Trays”, which is a tier of a
shuttle. When a tray is created, a tray type must be selected. When saved, the
tray location will automatically create as many sub-locations - called
“Cells” - as the tray type contains.</li>
<li>The tray type of a tray can be changed as long as none of its cell contains
products. When changed, it archives the cells and creates new ones as
configured on the new tray type.</li>
</ul>
</div>
<div class="section" id="tray-types">
<h2><a class="toc-backref" href="#id4">Tray types</a></h2>
<p>Tray types can be configured in the Inventory settings.
A tray type defines how much cells a tray can hold. It is a square or rectangle
matrix of n cols * m rows.</p>
</div>
<div class="section" id="vertical-lift-shuttles">
<h2><a class="toc-backref" href="#id5">Vertical Lift Shuttles</a></h2>
<p>The Shuttles are the Vertical Lift Trays. One Shuttle entity has to be created
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.</p>
</div>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id6">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Extract the tray types and matrix widget in a module, they can be used
alone without vertical lift</li>
<li>Consider merging the vertical_lift_kind with the kind added by
stock_location_zone</li>
<li>Complete Pick screen and workflow (currently enough for a demo, not for production)</li>
<li>Implement Put-away screen and workflow</li>
<li>Implement Inventory screen and workflow</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id7">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_vertical_lift%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id8">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id9">Authors</a></h2>
<ul class="simple">
<li>Camptocamp</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id10">Contributors</a></h2>
<ul class="simple">
<li>Guewen Baconnier &lt;<a class="reference external" href="mailto:guewen.baconnier&#64;camptocamp.com">guewen.baconnier&#64;camptocamp.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id11">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_vertical_lift">OCA/stock-logistics-warehouse</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,79 @@
odoo.define("stock_vertical_lift.vertical_lift", function(require) {
"use strict";
var KanbanRecord = require("web.KanbanRecord");
var FormController = require("web.FormController");
KanbanRecord.include({
_openRecord: function() {
if (
this.modelName === "vertical.lift.shuttle" &&
this.$el.hasClass("open_shuttle_screen")
) {
var self = this;
this._rpc({
method: "action_open_screen",
model: self.modelName,
args: [self.id],
}).then(function(action) {
self.trigger_up("do_action", {action: action});
});
} else {
this._super.apply(this, arguments);
}
},
});
FormController.include({
init: function() {
this._super.apply(this, arguments);
if (this.modelName.startsWith("vertical.lift.operation.")) {
this.call("bus_service", "addChannel", "notify_vertical_lift_screen");
this.call(
"bus_service",
"on",
"notification",
this,
this.vlift_bus_notification
);
this.call("bus_service", "startPolling");
}
},
vlift_bus_notification: function(notifications) {
var self = this;
_.each(notifications, function(notification) {
var channel = notification[0];
var message = notification[1];
if (channel === "notify_vertical_lift_screen") {
switch (message.action) {
case "refresh":
self.vlift_bus_action_refresh(message.params);
break;
}
}
});
},
vlift_bus_action_refresh: function(params) {
var selectedIds = this.getSelectedIds();
if (!selectedIds.length) {
return;
}
var currentId = selectedIds[0];
if (params.id === currentId && params.model === this.modelName) {
this.reload();
}
},
destroy: function() {
if (this.modelName.startsWith("vertical.lift.operation.")) {
this.call(
"bus_service",
"deleteChannel",
"notify_vertical_lift_screen"
);
}
this._super.apply(this, arguments);
},
});
return {};
});

View File

@@ -0,0 +1,135 @@
.o_web_client.o_fullscreen {
$o-shuttle-padding: $o-horizontal-padding;
.o_form_view.o_vlift_shuttle {
display: flex;
flex-flow: column nowrap;
padding: 0;
font-size: 16px;
@include media-breakpoint-up(xl) {
font-size: 18px;
}
.btn {
font-size: 1em;
padding: 1em;
margin: 0 5px;
}
.o_shuttle_header {
display: flex;
flex-flow: row wrap;
padding: $o-shuttle-padding;
}
.o_shuttle_header_content {
display: flex;
flex-flow: row nowrap;
font-size: 2em;
flex: 1 0 auto;
align-items: center;
width: 33%;
&.o_shuttle_header_right {
justify-content: flex-end;
}
}
.o_shuttle_actions {
display: flex;
flex-flow: row nowrap;
font-size: 1.2em;
padding: $o-shuttle-padding * 0.5;
}
.o_shuttle_operation {
text-align: center;
font-size: 2.5em;
padding: 0.5em;
color: #ffffff;
}
.o_shuttle_content {
display: flex;
flex-flow: row nowrap;
flex: 1 0 auto;
align-items: center;
&.o_shuttle_content_right {
justify-content: flex-end;
}
}
.o_shuttle_data {
display: flex;
flex-flow: row wrap;
padding: $o-shuttle-padding * 0.5;
.o_shuttle_data_content {
flex-flow: row nowrap;
font-size: 1.2em;
flex: 1 0 auto;
align-items: center;
width: 50%;
&.o_shuttle_tray {
display: flex;
justify-content: flex-end;
.o_group {
display: block;
}
}
.o_field_location_tray_matrix {
width: 450px;
}
}
.o_shuttle_highlight {
padding: 6px;
border-radius: 10px;
}
}
}
.o_vlift_shuttle_menu {
.btn {
margin-bottom: $o-shuttle-padding;
padding: 1em;
font-size: 2em;
text-transform: uppercase;
}
}
.o_vlift_shuttle_popup {
table tr {
line-height: 3;
font-size: 1.1em;
.o_list_record_remove {
width: 50px;
text-align: center;
}
}
.o_field_char {
padding: 1em;
font-size: 2em;
}
.btn {
padding: 1em;
font-size: 2em;
text-transform: uppercase;
}
}
footer .btn {
padding: 1em;
font-size: 2em;
text-transform: uppercase;
}
}

View File

@@ -0,0 +1,4 @@
from . import test_location
from . import test_inventory
from . import test_pick
from . import test_put

View File

@@ -0,0 +1,176 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _
from odoo.addons.stock_location_tray.tests import common
class VerticalLiftCase(common.LocationTrayTypeCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.shuttle = cls.env.ref(
"stock_vertical_lift.stock_vertical_lift_demo_shuttle_1"
)
cls.product_socks = cls.env.ref("stock_vertical_lift.product_running_socks")
cls.product_recovery = cls.env.ref("stock_vertical_lift.product_recovery_socks")
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"
)
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 = cls.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1b"
)
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"
)
@classmethod
def _update_qty_in_location(
cls, location, product, quantity, package=None, lot=None
):
quants = cls.env["stock.quant"]._gather(
product, location, lot_id=lot, package_id=package, strict=True
)
# this method adds the quantity to the current quantity, so remove it
quantity -= sum(quants.mapped("quantity"))
cls.env["stock.quant"]._update_available_quantity(
product, location, quantity, package_id=package, lot_id=lot
)
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")
customer_loc = cls.env.ref("stock.stock_location_customers")
picking_type = cls.env.ref("stock.picking_type_out")
partner = cls.env.ref("base.res_partner_1")
return cls.env["stock.picking"].create(
{
"picking_type_id": picking_type.id,
"partner_id": partner.id,
"location_id": stock_loc.id,
"location_dest_id": customer_loc.id,
"move_lines": [
(
0,
0,
{
"name": product.name,
"product_id": product.id,
"product_uom": product.uom_id.id,
"product_uom_qty": quantity,
"picking_type_id": picking_type.id,
"location_id": stock_loc.id,
"location_dest_id": customer_loc.id,
},
)
],
}
)
@classmethod
def _create_simple_picking_in(cls, product, quantity, dest_location):
supplier_loc = cls.env.ref("stock.stock_location_suppliers")
picking_type = cls.env.ref("stock.picking_type_in")
partner = cls.env.ref("base.res_partner_1")
return cls.env["stock.picking"].create(
{
"picking_type_id": picking_type.id,
"partner_id": partner.id,
"location_id": supplier_loc.id,
"location_dest_id": dest_location.id,
"move_lines": [
(
0,
0,
{
"name": product.name,
"product_id": product.id,
"product_uom": product.uom_id.id,
"product_uom_qty": quantity,
"picking_type_id": picking_type.id,
"location_id": supplier_loc.id,
"location_dest_id": dest_location.id,
},
)
],
}
)
@classmethod
def _create_inventory(cls, products):
"""Create a draft inventory
Products is a list of tuples (bin location, product).
"""
values = {
"name": "Test Inventory",
"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 = cls.env["stock.inventory"].create(values)
inventory.action_start()
return inventory
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)
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)

View File

@@ -0,0 +1,115 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _
from .common import VerticalLiftCase
class TestInventory(VerticalLiftCase):
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(),
)
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)])
operation = self._open_screen("inventory")
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)]
)
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
# noop because we have no further lines
self.assertEqual(operation.state, "noop")
self.assertFalse(operation.current_inventory_line_id)
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)]
)
operation = self._open_screen("inventory")
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)
# 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")
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

@@ -0,0 +1,37 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from .common import VerticalLiftCase
class TestVerticalLiftLocation(VerticalLiftCase):
def test_vertical_lift_kind(self):
# this boolean is what defines a "Vertical Lift View", the upper level
# of the tree (View -> Shuttles -> Trays -> Cells)
self.assertTrue(self.vertical_lift_loc.vertical_lift_location)
self.assertEqual(self.vertical_lift_loc.vertical_lift_kind, "view")
# check types accross the hierarchy
shuttles = self.vertical_lift_loc.child_ids
self.assertTrue(
all(location.vertical_lift_kind == "shuttle" for location in shuttles)
)
trays = shuttles.mapped("child_ids")
self.assertTrue(
all(location.vertical_lift_kind == "tray" for location in trays)
)
cells = trays.mapped("child_ids")
self.assertTrue(
all(location.vertical_lift_kind == "cell" for location in cells)
)
def test_create_shuttle(self):
# any location created directly under the view is a shuttle
shuttle_loc = self.env["stock.location"].create(
{
"name": "Shuttle 42",
"location_id": self.vertical_lift_loc.id,
"usage": "internal",
}
)
self.assertEqual(shuttle_loc.vertical_lift_kind, "shuttle")

View File

@@ -0,0 +1,202 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from .common import VerticalLiftCase
class TestPick(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_pick(self):
self.shuttle.switch_pick()
self.assertEqual(self.shuttle.mode, "pick")
self.assertEqual(
self.shuttle._operation_for_mode().current_move_line_id, self.out_move_line
)
def test_pick_action_open_screen(self):
self.shuttle.switch_pick()
action = self.shuttle.action_open_screen()
operation = self.shuttle._operation_for_mode()
self.assertTrue(operation.current_move_line_id)
self.assertEqual(action["type"], "ir.actions.act_window")
self.assertEqual(action["res_model"], "vertical.lift.operation.pick")
self.assertEqual(action["res_id"], operation.id)
def test_pick_select_next_move_line(self):
operation = self._open_screen("pick")
operation.select_next_move_line()
self.assertEqual(operation.current_move_line_id, self.out_move_line)
self.assertEqual(operation.state, "scan_destination")
def test_pick_save(self):
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.state, "release")
def test_pick_related_fields(self):
operation = self._open_screen("pick")
ml = operation.current_move_line_id = self.out_move_line
# Trays related fields
# For pick, this is the source location, which is the cell where the
# product is.
self.assertEqual(operation.tray_location_id, ml.location_id)
self.assertEqual(
operation.tray_name,
# parent = tray
ml.location_id.location_id.name,
)
self.assertEqual(
operation.tray_type_id,
# the tray type is on the parent of the cell (on the tray)
ml.location_id.location_id.tray_type_id,
)
self.assertEqual(
operation.tray_type_code, ml.location_id.location_id.tray_type_id.code
)
self.assertEqual(operation.tray_x, ml.location_id.posx)
self.assertEqual(operation.tray_y, ml.location_id.posy)
# Move line related fields
self.assertEqual(operation.picking_id, ml.picking_id)
self.assertEqual(operation.picking_origin, ml.picking_id.origin)
self.assertEqual(operation.picking_partner_id, ml.picking_id.partner_id)
self.assertEqual(operation.product_id, ml.product_id)
self.assertEqual(operation.product_uom_id, ml.product_uom_id)
self.assertEqual(operation.product_uom_qty, ml.product_uom_qty)
self.assertEqual(operation.qty_done, ml.qty_done)
self.assertEqual(operation.lot_id, ml.lot_id)
def test_pick_count_move_lines(self):
product1 = self.env.ref("stock_vertical_lift.product_running_socks")
product2 = self.env.ref("stock_vertical_lift.product_recovery_socks")
# cancel the picking from demo data to start from a clean state
self.env.ref(
"stock_vertical_lift.stock_picking_out_demo_vertical_lift_1"
).action_cancel()
# ensure that we have stock in some cells, we'll put product1
# in the first Shuttle and product2 in the second
cell1 = self.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x3y2"
)
self._update_quantity_in_cell(cell1, product1, 50)
cell2 = self.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2a_x1y1"
)
self._update_quantity_in_cell(cell2, product2, 50)
# create pickings (we already have an existing one from demo data)
pickings = self.env["stock.picking"].browse()
pickings |= self._create_simple_picking_out(product1, 1)
pickings |= self._create_simple_picking_out(product1, 1)
pickings |= self._create_simple_picking_out(product1, 1)
pickings |= self._create_simple_picking_out(product1, 1)
pickings |= self._create_simple_picking_out(product2, 20)
pickings |= self._create_simple_picking_out(product2, 30)
# this one should be 'assigned', so should be included in the operation
# count
unassigned = self._create_simple_picking_out(product2, 1)
pickings |= unassigned
pickings.action_confirm()
# product1 will be taken from the shuttle1, product2 from shuttle2
pickings.action_assign()
shuttle1 = self.shuttle
operation1 = shuttle1._operation_for_mode()
shuttle2 = self.env.ref(
"stock_vertical_lift.stock_vertical_lift_demo_shuttle_2"
)
operation2 = shuttle2._operation_for_mode()
self.assertEqual(operation1.number_of_ops, 4)
self.assertEqual(operation2.number_of_ops, 2)
self.assertEqual(operation1.number_of_ops_all, 6)
self.assertEqual(operation2.number_of_ops_all, 6)
# Process a line, should change the numbers.
operation1.select_next_move_line()
operation1.process_current()
self.assertEqual(operation1.number_of_ops, 3)
self.assertEqual(operation2.number_of_ops, 2)
self.assertEqual(operation1.number_of_ops_all, 5)
self.assertEqual(operation2.number_of_ops_all, 5)
# add stock and make the last one assigned to check the number is
# updated
self._update_quantity_in_cell(cell2, product2, 10)
unassigned.action_assign()
self.assertEqual(operation1.number_of_ops, 3)
self.assertEqual(operation2.number_of_ops, 3)
self.assertEqual(operation1.number_of_ops_all, 6)
self.assertEqual(operation2.number_of_ops_all, 6)
def test_on_barcode_scanned(self):
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")
self.assertEqual(
current_destination, self.env.ref("stock.stock_location_customers")
)
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._open_screen("pick")
self._test_button_release(self.out_move_line, "noop")
def test_process_current_pick(self):
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()
self.assertEqual(self.out_move_line.state, "done")
self.assertEqual(self.out_move_line.qty_done, qty_to_process)
def test_matrix(self):
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
expected_x = location.posx - 1
expected_y = location.posy - 1
self.assertEqual(
operation.tray_matrix,
{
"selected": [expected_x, expected_y],
# fmt: off
'cells': [
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0],
]
# fmt: on
},
)
def test_tray_qty(self):
cell = self.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x3y2"
)
self.out_move_line.location_id = cell
operation = self.shuttle._operation_for_mode()
operation.current_move_line_id = self.out_move_line
self._update_quantity_in_cell(cell, self.out_move_line.product_id, 50)
self.assertEqual(operation.tray_qty, 50)
self._update_quantity_in_cell(cell, self.out_move_line.product_id, -20)
self.assertEqual(operation.tray_qty, 30)

View File

@@ -0,0 +1,154 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from .common import VerticalLiftCase
class TestPut(VerticalLiftCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.picking_in = cls.env.ref(
"stock_vertical_lift.stock_picking_in_demo_vertical_lift_1"
)
cls.picking_in.action_confirm()
cls.in_move_line = cls.picking_in.move_line_ids
cls.in_move_line.location_dest_id = cls.shuttle.location_id
def test_put_action_open_screen(self):
self.shuttle.switch_put()
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.put")
self.assertEqual(action["res_id"], operation.id)
def test_switch_put(self):
self.shuttle.switch_put()
self.assertEqual(self.shuttle.mode, "put")
self.assertEqual(
self.shuttle._operation_for_mode().current_move_line_id,
self.env["stock.move.line"].browse(),
)
def test_put_count_move_lines(self):
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.vertical_lift_loc
)
put2.action_confirm()
put3 = self._create_simple_picking_in(
self.product_recovery, 10, self.vertical_lift_loc
)
put3.action_confirm()
operation = self._open_screen("put")
shuttle2 = self.env.ref(
"stock_vertical_lift.stock_vertical_lift_demo_shuttle_2"
)
operation2 = self._open_screen("put", shuttle=shuttle2)
# 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, 0)
self.assertEqual(operation2.number_of_ops_all, 3)
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_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.state, "scan_tray_type")
self.assertEqual(operation.current_move_line_id, 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(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.location_1a.child_ids
)
def test_change_tray_type_on_save(self):
operation = self._open_screen("put")
move_line = self.in_move_line
# assume we already scanned the product and the tray type
# and the assigned location was location_1a_x1y1
operation.current_move_line_id = move_line
move_line.location_dest_id = self.location_1a_x1y1
operation.state = "save"
# we want to use another tray with a different type though,
# so we scan again
operation.on_barcode_scanned(self.location_1b.tray_type_id.code)
self.assertTrue(
self.in_move_line.location_dest_id
in self.shuttle.location_id.child_ids.child_ids
)
# we are still in save
self.assertEqual(operation.state, "save")
# a cell has been set in the other tray
self.assertTrue(move_line.location_dest_id in self.location_1b.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):
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(self.in_move_line.state, "done")
self.assertEqual(self.in_move_line.qty_done, qty_to_process)
def test_transition_button_release(self):
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

@@ -0,0 +1,23 @@
<odoo>
<template id="packagings">
<ul class="o_vlift_packaging list-unstyled">
<t t-foreach="product.packaging_ids" t-as="packaging">
<li>
<span>1</span>
<span
class="packaging_name"
itemprop="name"
t-esc="packaging.name"
/>
<span>: </span>
<span class="packaging_qty" itemprop="qty" t-esc="packaging.qty" />
<span
class="packaging_unit"
itemprop="unit"
t-esc="product.uom_id.name"
/>
</li>
</t>
</ul>
</template>
</odoo>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_location_form" model="ir.ui.view">
<field name="name">stock.location.form.vertical.lift</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock_location_tray.view_location_form" />
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button
name="button_fetch_vertical_lift_tray"
string="Fetch Shuttle Tray"
type="object"
groups="stock.group_stock_manager"
class="oe_stat_button"
icon="fa-hand-paper-o"
attrs="{'invisible': [('vertical_lift_kind', 'not in', ('tray', 'cell'))]}"
/>
<button
name="button_release_vertical_lift_tray"
string="Release Shuttle Tray"
type="object"
groups="stock.group_stock_manager"
class="oe_stat_button"
icon="fa-hand-rock-o"
attrs="{'invisible': [('vertical_lift_kind', 'not in', ('tray', 'cell'))]}"
/>
</div>
<field name="return_location" position="after">
<field
name="vertical_lift_location"
attrs="{'invisible': [('vertical_lift_kind', '!=', False), ('vertical_lift_kind', '!=', 'view')]}"
/>
<field name="vertical_lift_kind" />
<field
name="vertical_lift_shuttle_id"
attrs="{'invisible': [('vertical_lift_kind', 'not in', ('shuttle', 'tray', 'cell'))]}"
/>
</field>
<field name="tray_type_id" position="attributes">
<attribute name="attrs">
{'invisible': [('cell_in_tray_type_id', '!=', False)],
'required': [('vertical_lift_kind', '=', 'tray')]}
</attribute>
</field>
</field>
</record>
<record id="view_location_search" model="ir.ui.view">
<field name="name">stock.location.search.vertical.lift</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock_location_tray.view_location_search" />
<field name="arch" type="xml">
<field name="tray_type_id" position="after">
<field name="vertical_lift_kind" />
<field name="vertical_lift_shuttle_id" />
</field>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_stock_move_line_operation_tree" model="ir.ui.view">
<field name="name">stock.move.line.operations.tree.vertical.lift</field>
<field name="model">stock.move.line</field>
<field
name="inherit_id"
ref="stock_location_tray.view_stock_move_line_operation_tree"
/>
<field name="arch" type="xml">
<button name="action_show_source_tray" position="before">
<button
name="fetch_vertical_lift_tray_source"
string="Fetch Source Tray"
type="object"
icon="fa-hand-paper-o"
attrs="{'invisible': [('tray_source_matrix', '=', {})]}"
invisible="not context.get('show_source_location')"
groups="stock.group_stock_multi_locations"
/>
</button>
<button name="action_show_dest_tray" position="before">
<button
name="fetch_vertical_lift_tray_dest"
string="Fetch Destination Tray"
type="object"
icon="fa-hand-paper-o"
attrs="{'invisible': [('tray_dest_matrix', '=', {})]}"
invisible="not context.get('show_destination_location')"
groups="stock.group_stock_multi_locations"
/>
</button>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template
id="stock_vertical_lift_assets"
name="stock.vertical.lift.assets"
inherit_id="web.assets_backend"
>
<xpath expr="." position="inside">
<link
rel="stylesheet"
type="text/scss"
href="/stock_vertical_lift/static/src/scss/vertical_lift.scss"
/>
<script
type="text/javascript"
src="/stock_vertical_lift/static/src/js/vertical_lift.js"
/>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,210 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!--
When we refresh the page on the browser when we are using the
"screen" view, odoo loses the information that we want the view
to be headless, fullscreen, etc. so it's displayed pretty badly.
This view is a work-around: its priority is lower, so it will be
picked up by default on loading, and a button allows to re-open
the screen view with the proper options.
-->
<record id="vertical_lift_operation_base_button_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.base.button.view</field>
<field name="model">vertical.lift.operation.base</field>
<field name="arch" type="xml">
<form string="Operations" delete="0" create="0">
<button
string="Reopen Screen"
name="action_open_screen"
type="object"
class="btn-primary"
/>
</form>
</field>
</record>
<record id="vertical_lift_operation_base_screen_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.base.screen.view</field>
<field name="model">vertical.lift.operation.base</field>
<field name="arch" type="xml">
<form string="Operations" delete="0" create="0" class="o_vlift_shuttle">
<div class="o_shuttle_header">
<div class="o_shuttle_header_left o_shuttle_header_content">
<field name="name" readonly="1" />
</div>
<div class="o_shuttle_header_center o_shuttle_header_content">
<label for="mode" />
<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" />
<span>/</span>
<field name="number_of_ops_all" readonly="1" />
</div>
</div>
<div class="o_shuttle_actions">
<div class="o_shuttle_content o_shuttle_content_left">
<button
name="action_menu"
type="object"
class="btn-secondary o_shuttle_icon_btn"
string=""
icon="fa-bars"
aria-label="Dropdown menu"
title="Dropdown menu"
/>
<button
name="action_manual_barcode"
type="object"
class="btn-secondary o_shuttle_icon_btn"
string=""
icon="fa-terminal"
aria-label="Barcode Input"
title="Barcode Input"
/>
</div>
<div class="o_shuttle_content o_shuttle_content_right">
<div>
<!-- will react on barcode 'O-BTN.save' -->
<button
name="button_save"
type="object"
string="Save"
icon="fa-check"
class="btn-primary"
barcode_trigger="save"
attrs="{'invisible': [('state', '!=', 'save')]}"
/>
<!-- will react on barcode 'O-BTN.release -->
<button
name="button_release"
type="object"
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="state" invisible="0" readonly="True" />
</div>
</div>
<div class="o_shuttle_data">
</div>
<field name="_barcode_scanned" widget="barcode_handler" />
</form>
</field>
</record>
<record id="vertical_lift_operation_transfer_screen_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.transfer.screen.view</field>
<field name="model">vertical.lift.operation.transfer</field>
<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_data')]" position="attributes">
<attribute
name="attrs"
>{'invisible': [('current_move_line_id', '=', False)]}</attribute>
</xpath>
<xpath expr="//div[hasclass('o_shuttle_data')]" position="inside">
<!-- on the left of the screen -->
<div class="o_shuttle_data_content o_shuttle_move">
<div>
<group>
<field name="current_move_line_id" invisible="1" />
<label for="picking_id" />
<div>
<div>
<field
name="picking_id"
options="{'no_open': True}"
class="mr8"
/>
<span>/</span>
<field
name="picking_origin"
class="oe_inline ml8"
/>
</div>
</div>
<label for="picking_partner_id" />
<div>
<field
name="picking_partner_id"
options="{'no_open': True}"
/>
</div>
<label for="location_dest_id" />
<div>
<field
name="location_dest_id"
class="bg-primary o_shuttle_highlight"
readonly="1"
options="{'no_open': True}"
/>
</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" />
<label
for="product_uom_qty"
string="Quantity"
class="ml32"
/>
<div colspan="2" class="ml32">
<h1 class="bg-primary o_shuttle_highlight">
<field
name="product_uom_qty"
class="oe_inline text-white"
/>
<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"
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_x" />
<field name="tray_y" />
<label for="tray_qty" />
<div colspan="2" class="oe_title">
<h1>
<field name="tray_qty" />
</h1>
</div>
</group>
<group>
<div>
<field name="tray_matrix" widget="location_tray_matrix" />
</div>
</group>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="vertical_lift_operation_inventory_button_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.inventory.button.view</field>
<field name="model">vertical.lift.operation.inventory</field>
<field name="inherit_id" ref="vertical_lift_operation_base_button_view" />
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Inventory Screen</attribute>
</form>
</field>
</record>
<record id="vertical_lift_operation_inventory_screen_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.inventory.screen.view</field>
<field name="model">vertical.lift.operation.inventory</field>
<field name="inherit_id" ref="vertical_lift_operation_base_screen_view" />
<field name="priority">100</field>
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Inventory Screen</attribute>
</form>
<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>
<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">
<!-- 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"
attrs="{'readonly': [('state', 'not in', ('quantity', 'confirm_wrong_quantity'))]}"
/>
<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_x" />
<field name="tray_y" />
</group>
<group>
<div>
<field name="tray_matrix" widget="location_tray_matrix" />
</div>
</group>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="vertical_lift_operation_pick_button_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.pick.button.view</field>
<field name="model">vertical.lift.operation.pick</field>
<field name="inherit_id" ref="vertical_lift_operation_base_button_view" />
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Pick Screen</attribute>
</form>
</field>
</record>
<record id="vertical_lift_operation_pick_screen_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.pick.screen.view</field>
<field name="model">vertical.lift.operation.pick</field>
<field name="inherit_id" ref="vertical_lift_operation_transfer_screen_view" />
<field name="priority">100</field>
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Pick Screen</attribute>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="vertical_lift_operation_put_button_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.put.button.view</field>
<field name="model">vertical.lift.operation.put</field>
<field name="inherit_id" ref="vertical_lift_operation_base_button_view" />
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Put-Away Screen</attribute>
</form>
</field>
</record>
<record id="vertical_lift_operation_put_screen_view" model="ir.ui.view">
<field name="name">vertical.lift.operation.put.screen.view</field>
<field name="model">vertical.lift.operation.put</field>
<field name="inherit_id" ref="vertical_lift_operation_transfer_screen_view" />
<field name="priority">100</field>
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Put-Away Screen</attribute>
</form>
<xpath
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"
>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,216 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="vertical_lift_shuttle_form_menu" model="ir.ui.view">
<field name="name">vertical.lift.shuttle.view.form.menu</field>
<field name="model">vertical.lift.shuttle</field>
<field name="priority">100</field>
<field name="arch" type="xml">
<form string="Shuttle">
<div class="row o_vlift_shuttle_menu">
<div class="col-6">
<button
name="switch_pick"
type="object"
string="Pick"
class="btn-primary btn-block btn btn-lg"
/>
<button
name="switch_put"
type="object"
string="Put"
class="btn-primary btn-block btn btn-lg"
/>
<button
name="switch_inventory"
type="object"
string="Inventory"
class="btn-primary btn-block btn btn-lg"
/>
</div>
<div class="col-6">
<button
name="action_back_to_settings"
type="object"
string="Back to settings"
class="btn-danger btn-block btn-lg"
/>
</div>
</div>
<footer />
</form>
</field>
</record>
<record id="vertical_lift_shuttle_manual_barcode_form" model="ir.ui.view">
<field name="name">vertical.lift.shuttle.manual.barcode.view.form</field>
<field name="model">vertical.lift.shuttle.manual.barcode</field>
<field name="arch" type="xml">
<form string="Shuttle">
<div class="row o_vlift_shuttle_popup">
<div class="col-8">
<field name="barcode" />
</div>
<div class="col-2">
<button
name="button_save"
string="Confirm"
type="object"
class="btn-primary"
/>
</div>
</div>
<footer />
</form>
</field>
</record>
<record id="vertical_lift_shuttle_view_form" model="ir.ui.view">
<field name="name">vertical.lift.shuttle.view.form</field>
<field name="model">vertical.lift.shuttle</field>
<field name="arch" type="xml">
<form string="Shuttle">
<header>
<button
name="release_vertical_lift_tray"
string="Release tray"
type="object"
/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
</div>
<div class="oe_title">
<label class="oe_edit_only" for="name" />
<h1>
<field name="name" placeholder="Shuttle Name" />
</h1>
</div>
<group name="main">
<group name="left">
<field name="mode" />
<field name="location_id" />
<field name="hardware" />
</group>
<group string="Network" name="network">
<field name="server" />
<field name="port" />
<field name="use_tls" />
</group>
</group>
<group groups="base.group_no_one">
<label for="command_ids" />
<field name="command_ids">
<tree>
<field name="name" />
<field name="command" />
<field name="answer" />
<field name="error" />
<field name="create_date" />
</tree>
</field>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="vertical_lift_shuttle_view_kanban">
<field name="name">vertical.lift.shuttle.kanban</field>
<field name="model">vertical.lift.shuttle</field>
<field name="arch" type="xml">
<kanban class="o_kanban_dashboard">
<field name="name" />
<field name="mode" />
<!-- <field name="number_of_ops"/> -->
<!-- <field name="number_of_ops_all"/> -->
<templates>
<t t-name="kanban-box">
<div
class="oe_kanban_global_click o_has_icon open_shuttle_screen"
>
<div class="o_vlift_kanban_main">
<div class="o_kanban_card_content">
<div class="o_kanban_primary_left">
<div class="o_primary">
<strong class="o_kanban_record_title">
<span>
<t t-esc="record.name.value" />
</span>
</strong>
</div>
<div class="row">
<div class="col-8">
Mode:
<field name="mode" />
</div>
<!-- <div class="col-8"> -->
<!-- Operations: -->
<!-- <field name="number_of_ops"/> -->
<!-- </div> -->
<!-- <div class="col-8"> -->
<!-- All <t t-esc="record.mode.value"/> Operations: -->
<!-- <field name="number_of_ops_all"/> -->
<!-- </div> -->
</div>
</div>
</div>
<div
class="o_kanban_card_manage_pane dropdown-menu"
groups="stock.group_stock_manager"
role="menu"
>
<div
class="o_kanban_card_manage_section o_kanban_manage_reports"
>
<div role="menuitem">
<a type="edit">Settings</a>
</div>
</div>
</div>
<a
class="o_kanban_manage_toggle_button o_left"
href="#"
groups="stock.group_stock_manager"
>
<i
class="fa fa-ellipsis-v"
role="img"
aria-label="Manage"
title="Manage"
/>
</a>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record model="ir.ui.view" id="vertical_lift_shuttle_view_tree">
<field name="name">vertical.lift.shuttle.tree</field>
<field name="model">vertical.lift.shuttle</field>
<field name="arch" type="xml">
<tree string="Shuttle">
<field name="name" />
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="vertical_lift_shuttle_action">
<field name="name">Vertical Lift Shuttles</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">vertical.lift.shuttle</field>
<field name="view_mode">kanban,tree,form</field>
<field name="target">current</field>
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Open the Shuttle Interface.
</p>
</field>
</record>
<menuitem
id="vertical_lift_shuttle"
name="Vertical Lift Shuttles"
action="vertical_lift_shuttle_action"
parent="stock.menu_stock_warehouse_mgmt"
sequence="15"
/>
</odoo>

View File

@@ -0,0 +1,84 @@
======================
Vertical Lift - Kardex
======================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
:alt: Alpha
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github
:target: https://github.com/OCA/stock-logistics-warehouse/tree/13.0/stock_vertical_lift_kardex
:alt: OCA/stock-logistics-warehouse
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-13-0/stock-logistics-warehouse-13-0-stock_vertical_lift_kardex
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/153/13.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
Add support for Kardex Remstar vertical lifts to the Vertical Lift
module.
.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
`More details on development status <https://odoo-community.org/page/development-status>`_
**Table of contents**
.. contents::
:local:
Known issues / Roadmap
======================
* Add support of the hardware
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/stock-logistics-warehouse/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_vertical_lift_kardex%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Camptocamp
Contributors
~~~~~~~~~~~~
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/stock-logistics-warehouse <https://github.com/OCA/stock-logistics-warehouse/tree/13.0/stock_vertical_lift_kardex>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,15 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Vertical Lift - Kardex",
"summary": "Integrate with Kardex Remstar Vertical Lifts",
"version": "13.0.1.0.0",
"category": "Stock",
"author": "Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": ["stock_vertical_lift", "stock_location_position"],
"website": "https://www.camptocamp.com",
"data": [],
"installable": True,
"development_status": "Alpha",
}

View File

@@ -0,0 +1,2 @@
from . import stock_location
from . import vertical_lift_shuttle

View File

@@ -0,0 +1,98 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, exceptions, models
_logger = logging.getLogger(__name__)
class StockLocation(models.Model):
_inherit = "stock.location"
def _hardware_kardex_prepare_fetch_payload(self, cell_location=None):
if self.level is False:
raise exceptions.UserError(
_("Shuttle tray %s has no level. " "Please fix the configuration")
% self.display_name
)
shuttle = self.vertical_lift_shuttle_id
if cell_location:
x, y = cell_location.tray_cell_center_position()
if x == 0 and y == 0:
raise exceptions.UserError(
_(
"Cell location %s has no position. "
"Check if the dimensions of tray %s "
"are properly set in the tray type."
)
% (cell_location.display_name, self.name)
)
x, y = int(x), int(y)
else:
x, y = "", ""
subst = {
"code": shuttle._kardex_shuttle_code(),
"hostId": self.env["ir.sequence"].next_by_code("vertical.lift.command"),
# hard code the gate for now.
# TODO proper handling of multiple gates for 1 lift.
"addr": shuttle.name + "-1",
"carrier": self.level,
"carrierNext": "0",
"x": x,
"y": y,
"boxType": "",
"Q": "",
"order": "",
"part": "",
"desc": "",
}
return shuttle._hardware_kardex_format_template(subst)
def _hardware_vertical_lift_fetch_tray_payload(self, cell_location=None):
"""Prepare "fetch" message to be sent to the vertical lift hardware
Private method, this is where the implementation actually happens.
Addons can add their instructions based on the hardware used for
this location.
The hardware used for a location can be found in:
``self.vertical_lift_shuttle_id.hardware``
Each addon can implement its own mechanism depending of this value
and must call ``super``.
The method must send the command to the vertical lift to fetch / open
the tray. If a ``cell_location`` is passed and if the hardware supports
a way to show a cell (such as a laser pointer), it should send this
command as well.
Useful information that could be needed for the drivers:
* Any field of `self` (name, barcode, ...) which is the current tray.
* Any field of `cell_location` (name, barcode, ...) which is the cell
in the tray.
* ``self.vertical_lift_shuttle_id`` is the current Shuttle, where we
find details about the hardware, the current mode (pick, put, ...).
* ``self.tray_type_id`` is the kind of tray.
* ``self.tray_type_id.width_per_cell`` and
``self.tray_type_id.depth_per_cell`` return the size of a cell in mm.
* ``cell_location.posx`` and ``posy`` are the coordinate from the
bottom-left of the tray.
* ``cell_location.tray_cell_center_position()`` returns the central
position of the cell in mm from the bottom-left of a tray. (distance
from left, distance from bottom). Can be used for instance for
highlighting the cell using a laser pointer.
"""
if self.vertical_lift_shuttle_id.hardware == "kardex":
payload = self._hardware_kardex_prepare_fetch_payload(
cell_location=cell_location
)
_logger.debug("Sending to kardex (fetch): {}", payload)
else:
payload = super()._hardware_vertical_lift_fetch_tray_payload(
cell_location=cell_location
)
return payload

View File

@@ -0,0 +1,123 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import models
_logger = logging.getLogger(__name__)
JMIF_STATUS = {
0: "success",
101: "common error",
102: "sequence number invalid",
103: "machine busy",
104: "timeout",
105: "max retry reached",
106: "carrier in use or undefined",
107: "cancelled",
108: "invalid user input data",
201: "request accepted and queued",
202: "request processing started / request active",
203: "carrier arrived, maybe overwritten by code 0",
301: "AO occupied with other try on move back (store / put)",
302: "AO occupied with other try on fetch (pick)",
}
class VerticalLiftShuttle(models.Model):
_inherit = "vertical.lift.shuttle"
def _selection_hardware(self):
values = super()._selection_hardware()
values += [("kardex", "Kardex")]
return values
_kardex_message_template = (
"{code}|{hostId}|{addr}|{carrier}|{carrierNext}|"
"{x}|{y}|{boxType}|{Q}|{order}|{part}|{desc}|\r\n"
)
def _hardware_kardex_format_template(self, values):
payload = self._kardex_message_template.format(**values)
return payload.encode("iso-8859-1", "replace")
def _kardex_shuttle_code(self):
mapping = {"pick": "1", "put": "2", "inventory": "5"}
ping = "61"
return mapping.get(self.mode, ping)
def _hardware_kardex_prepare_release_payload(self):
subst = {
"code": self._kardex_shuttle_code(),
"hostId": self.env["ir.sequence"].next_by_code("vertical.lift.command"),
# hard code the gate for now.
"addr": self.name + "-1",
"carrier": "0",
"carrierNext": "0",
"x": "0",
"y": "0",
"boxType": "",
"Q": "",
"order": "",
"part": "",
"desc": "",
}
return self._hardware_kardex_format_template(subst)
def _hardware_vertical_lift_release_tray_payload(self):
"""Prepare "release" message to be sent to the vertical lift hardware
Private method, this is where the implementation actually happens.
Addons can add their instructions based on the hardware used for
this location.
The hardware used for a location can be found in:
``self.vertical_lift_shuttle_id.hardware``
Each addon can implement its own mechanism depending of this value
and must call ``super``.
The method must send the command to the vertical lift to release (close)
the tray.
Returns a message in bytes, that will be sent through
``VerticalLiftShuttle._hardware_send_message()``.
"""
if self.hardware == "kardex":
payload = self._hardware_kardex_prepare_release_payload()
_logger.debug("Sending to kardex (release): {}", payload)
else:
payload = super()._hardware_vertical_lift_release_tray_payload()
return payload
def _check_server_response(self, command):
response = command.answer
code, sep, remaining = response.partition("|")
code = int(code)
if code == 0:
return True
elif 1 <= code <= 99:
command.error = "interface error %d" % code
return False
elif code in JMIF_STATUS and code < 200:
command.error = "%d: %s" % (code, JMIF_STATUS[code])
return False
elif code in JMIF_STATUS and code < 300:
command.error = "%d: %s" % (code, JMIF_STATUS[code])
return True
elif code in JMIF_STATUS:
command.error = "%d: %s" % (code, JMIF_STATUS[code])
elif 501 <= code <= 999:
command.error = "%d: %s" % (code, "MM260 Error")
elif 1000 <= code <= 32767:
command.error = "%d: %s" % (code, "C2000TCP/C3000CGI machine error")
elif 0xFF0 <= code == 0xFFF:
command.error = "{:x}: {}".format(
code, "C3000CGI machine error (global short)"
)
elif 0xFFF < code:
command.error = "{:x}: {}".format(code, "C3000CGI machine error (long)")
return False

View File

@@ -0,0 +1,172 @@
#!/usr/bin/python3
import argparse
import asyncio
import logging
import os
import ssl
import time
import aiohttp
_logger = logging.getLogger(__name__)
class KardexProxyProtocol(asyncio.Protocol):
def __init__(self, loop, queue, args):
_logger.info("Proxy created")
self.transport = None
self.buffer = b""
self.queue = queue
self.loop = loop
self.args = args
def connection_made(self, transport):
_logger.info("Proxy incoming cnx")
self.transport = transport
self.buffer = b""
def data_received(self, data):
self.buffer += data
_logger.info("Proxy: received %s", data)
if len(self.buffer) > 65535:
# prevent buffer overflow
self.transport.close()
def eof_received(self):
_logger.info("Proxy: received EOF")
if self.buffer[-1] != b"\n":
# bad format -> close
self.transport.close()
data = (
self.buffer.replace(b"\r\n", b"\n")
.replace(b"\n", b"\r\n")
.decode("iso-8859-1", "replace")
)
self.loop.create_task(self.queue.put(data))
self.buffer = b""
def connection_lost(self, exc):
self.transport = None
self.buffer = b""
class KardexClientProtocol(asyncio.Protocol):
def __init__(self, loop, queue, args):
_logger.info("started kardex client")
self.loop = loop
self.queue = queue
self.args = args
self.transport = None
self.buffer = b""
def connection_made(self, transport):
self.transport = transport
_logger.info("connected to kardex server %r", transport)
async def keepalive(self):
while True:
t = int(time.time())
msg = "61|ping%d|SH1-1|0|0||||||||\r\n" % t
await self.send_message(msg)
await asyncio.sleep(20)
async def send_message(self, message):
_logger.info("SEND %r", message)
message = message.encode("iso-8859-1")
self.transport.write(message)
async def process_queue(self):
while True:
message = await self.queue.get()
await self.send_message(message)
def data_received(self, data):
data = data.replace(b"\0", b"")
_logger.info("RECV %s", data)
self.buffer += data
if b"\r\n" in self.buffer:
msg, sep, rem = self.buffer.partition(b"\r\n")
self.buffer = rem
msg = msg.decode("iso-8859-1", "replace").strip()
if msg.startswith("0|ping"):
_logger.info("ping ok")
else:
_logger.info("notify odoo: %s", msg)
self.loop.create_task(self.notify_odoo(msg))
def connection_lost(self, exc):
self.loop.stop()
async def notify_odoo(self, msg):
url = self.args.odoo_url + "/vertical-lift"
async with aiohttp.ClientSession() as session:
params = {"answer": msg, "secret": self.args.secret}
async with session.post(url, data=params) as resp:
resp_text = await resp.text()
_logger.info("Reponse from Odoo: %s %s", resp.status, resp_text)
def main(args, ssl_context=None):
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
loop = asyncio.get_event_loop()
queue = asyncio.Queue(loop=loop)
# create the main server
coro = loop.create_server(
lambda: KardexProxyProtocol(loop, queue, args), host=args.host, port=args.port
)
loop.run_until_complete(coro)
# create the connection to the JMIF client
if args.kardex_use_tls:
if ssl_context is None:
ssl_context = ssl.create_default_context()
else:
ssl_context = None
coro = loop.create_connection(
lambda: KardexClientProtocol(loop, queue, args),
host=args.kardex_host,
port=args.kardex_port,
ssl=ssl_context,
)
transport, client = loop.run_until_complete(coro)
loop.create_task(client.keepalive())
loop.create_task(client.process_queue())
loop.run_forever()
loop.close()
def make_parser():
listen_address = os.environ.get("INTERFACE", "0.0.0.0")
listen_port = int(os.environ.get("PORT", "7654"))
secret = os.environ.get("ODOO_CALLBACK_SECRET", "")
odoo_url = os.environ.get("ODOO_URL", "http://localhost:8069")
odoo_db = os.environ.get("ODOO_DB", "odoodb")
kardex_host = os.environ.get("KARDEX_HOST", "kardex")
kardex_port = int(os.environ.get("KARDEX_PORT", "9600"))
kardex_use_tls = (
False
if os.environ.get("KARDEX_TLS", "") in ("", "0", "False", "FALSE")
else True
)
parser = argparse.ArgumentParser()
arguments = [
("--host", listen_address, str),
("--port", listen_port, int),
("--odoo-url", odoo_url, str),
("--odoo-db", odoo_db, str),
("--secret", secret, str),
("--kardex-host", kardex_host, str),
("--kardex-port", kardex_port, str),
("--kardex-use-tls", kardex_use_tls, bool),
]
for name, default, type_ in arguments:
parser.add_argument(name, default=default, action="store", type=type_)
return parser
if __name__ == "__main__":
parser = make_parser()
args = parser.parse_args()
main(args)

View File

@@ -0,0 +1 @@
aiohttp

View File

@@ -0,0 +1,106 @@
# pylint: disable=W8116
import asyncio
import logging
import socket
import time
_logger = logging.getLogger("kardex.proxy")
logging.basicConfig(level=logging.DEBUG)
class KardexProxyProtocol(asyncio.Protocol):
def __init__(self, loop, queue):
_logger.info("Proxy created")
self.transport = None
self.buffer = b""
self.queue = queue
self.loop = loop
def connection_made(self, transport):
_logger.info("Proxy incoming cnx")
self.transport = transport
self.buffer = b""
def data_received(self, data):
self.buffer += data
_logger.info("Proxy: received %s", data)
if len(self.buffer) > 65535:
# prevent buffer overflow
self.transport.close()
def eof_received(self):
_logger.info("Proxy: received EOF")
if self.buffer[-1] != b"\n":
# bad format -> close
self.transport.close()
data = (
self.buffer.replace(b"\r\n", b"\n")
.replace(b"\n", b"\r\n")
.decode("iso-8859-1", "replace")
)
task = self.loop.create_task(self.queue.put(data))
self.buffer = b""
print("toto", task)
def connection_lost(self, exc):
self.transport = None
self.buffer = b""
class KardexClientProtocol(asyncio.Protocol):
def __init__(self, loop, queue):
_logger.info("started kardex client")
self.loop = loop
self.queue = queue
self.transport = None
self.buffer = b""
def connection_made(self, transport):
self.transport = transport
_logger.info("connected to kardex server %r", transport)
async def keepalive(self):
while True:
t = int(time.time())
msg = "61|ping%d|SH1-1|0|0||||||||\r\n" % t
await self.send_message(msg)
await asyncio.sleep(5)
async def send_message(self, message):
_logger.info("SEND %s", message)
message = message.encode("iso-8859-1").ljust(1024, b"\0")
self.transport.write(message)
async def process_queue(self):
while True:
message = await self.queue.get()
await self.send_message(message)
def data_received(self, data):
data = data.replace(b"\0", b"")
_logger.info("RECV %s", data)
self.buffer += data
def connection_lost(self, exc):
self.loop.stop()
if __name__ == "__main__":
_logger.info("starting")
loop = asyncio.get_event_loop()
loop.set_debug(1)
queue = asyncio.Queue(loop=loop)
coro = loop.create_server(
lambda: KardexProxyProtocol(loop, queue), port=3000, family=socket.AF_INET
)
server = loop.run_until_complete(coro)
coro = loop.create_connection(
lambda: KardexClientProtocol(loop, queue), "localhost", 9600
)
transport, client = loop.run_until_complete(coro)
print("%r" % transport)
loop.create_task(client.keepalive())
loop.create_task(client.process_queue())
_logger.info("run loop")
loop.run_forever()
loop.close()

View File

@@ -0,0 +1 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>

View File

@@ -0,0 +1,2 @@
Add support for Kardex Remstar vertical lifts to the Vertical Lift
module.

View File

@@ -0,0 +1,2 @@
* Add support of the hardware
* handle multiple gates for one lift

View File

@@ -0,0 +1 @@
aiohttp

View File

@@ -0,0 +1,433 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.14: http://docutils.sourceforge.net/" />
<title>Vertical Lift - Kardex</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="vertical-lift-kardex">
<h1 class="title">Vertical Lift - Kardex</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/13.0/stock_vertical_lift_kardex"><img alt="OCA/stock-logistics-warehouse" src="https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/stock-logistics-warehouse-13-0/stock-logistics-warehouse-13-0-stock_vertical_lift_kardex"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/153/13.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>Add support for Kardex Remstar vertical lifts to the Vertical Lift
module.</p>
<div class="admonition important">
<p class="first admonition-title">Important</p>
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
<a class="reference external" href="https://odoo-community.org/page/development-status">More details on development status</a></p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#known-issues-roadmap" id="id1">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id1">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>Add support of the hardware</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_vertical_lift_kardex%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id4">Authors</a></h2>
<ul class="simple">
<li>Camptocamp</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id5">Contributors</a></h2>
<ul class="simple">
<li>Guewen Baconnier &lt;<a class="reference external" href="mailto:guewen.baconnier&#64;camptocamp.com">guewen.baconnier&#64;camptocamp.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id6">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/13.0/stock_vertical_lift_kardex">OCA/stock-logistics-warehouse</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Vertical Lift Packaging type",
"summary": "Provides integration with Vertical Lifts and packaging types",
"version": "13.0.1.0.0",
"category": "Stock",
"author": "Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": [
"stock_vertical_lift",
# OCA / product-attribute
"product_packaging_type",
],
"website": "https://github.com/OCA/stock-logistics-warehouse",
"data": ["views/shuttle_screen_templates.xml"],
"installable": True,
"auto_install": True,
"development_status": "Alpha",
}

View File

@@ -0,0 +1 @@
* Simone Orsi <simahawk@gmail.com>

View File

@@ -0,0 +1,5 @@
Glue module for `product_packaging_type` (from OCA/product-attribute)
and `stock_vertical_lift`.
If both module are installed this module automatically gets installed
and displays the packaging type's full name instead of the packaging's name.

View File

@@ -0,0 +1,7 @@
<odoo>
<template id="packagings" inherit_id="stock_vertical_lift.packagings">
<xpath expr="//span[hasclass('packaging_name')]" position="attributes">
<attribute name="t-esc">packaging.packaging_type_id.display_name</attribute>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,16 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Vertical Lift - Server Environment",
"summary": "Server Environment layer for Vertical Lift",
"version": "13.0.1.0.0",
"category": "Stock",
"author": "Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": ["stock_vertical_lift", "server_environment"], # OCA/server-env
"website": "https://github.com/OCA/stock-logistics-warehouse",
"data": [],
"installable": True,
"auto_install": True,
"development_status": "Alpha",
}

View File

@@ -0,0 +1 @@
from . import vertical_lift_shuttle

View File

@@ -0,0 +1,24 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class VerticalLiftShuttle(models.Model):
_name = "vertical.lift.shuttle"
_inherit = ["vertical.lift.shuttle", "server.env.mixin"]
@property
def _server_env_fields(self):
base_fields = super()._server_env_fields
sftp_fields = {
"hardware": {"compute_default": "_compute_default_hardware"},
"server": {},
"port": {},
"use_tls": {},
}
sftp_fields.update(base_fields)
return sftp_fields
def _compute_default_hardware(self):
self.hardware = "simulation"

View File

@@ -0,0 +1 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>

View File

@@ -0,0 +1 @@
Adds OCA Server Environment features to Stock Vertical Lift.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,21 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Vertical Lift - Storage Type",
"summary": "Compatibility layer for storage types on vertical lifts",
"version": "13.0.1.0.0",
"category": "Stock",
"author": "Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": [
"stock_vertical_lift", # OCA/stock-logistics-warehouse
"stock_storage_type", # OCA/wms
],
"website": "https://github.com/OCA/stock-logistics-warehouse",
"data": [
"views/stock_location_tray_type_views.xml",
"views/stock_location_views.xml",
],
"installable": True,
"development_status": "Alpha",
}

View File

@@ -0,0 +1,4 @@
from . import stock_location
from . import stock_location_tray_type
from . import stock_quant
from . import vertical_lift_operation_put

View File

@@ -0,0 +1,50 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, api, exceptions, models
class StockLocation(models.Model):
_inherit = "stock.location"
@api.model_create_multi
def create(self, vals_list):
if not self.env.context.get("_sync_tray_type"):
for vals in vals_list:
if vals.get("tray_type_id") and vals.get("location_storage_type_ids"):
raise exceptions.UserError(
_(
"Error creating '{}': Location storage"
" type must be set on the tray type"
).format(vals.get("name"))
)
records = super().create(vals_list)
records._sync_tray_type_storage_types()
return records
def write(self, values):
if not self.env.context.get("_sync_tray_type"):
if values.get("location_storage_type_ids"):
if values.get("tray_type_id"):
has_tray_type = self
else:
has_tray_type = self.filtered("tray_type_id")
if has_tray_type:
raise exceptions.UserError(
_(
"Error updating {}: Location storage"
" type must be set on the tray type"
).format(", ".join(has_tray_type.mapped("name")))
)
res = super().write(values)
if values.get("tray_type_id"):
self._sync_tray_type_storage_types()
return res
def _sync_tray_type_storage_types(self):
for location in self.with_context(_sync_tray_type=True):
if not location.tray_type_id:
continue
storage_types = location.tray_type_id.location_storage_type_ids
location.write({"location_storage_type_ids": [(6, 0, storage_types.ids)]})

View File

@@ -0,0 +1,29 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class StockLocationTrayType(models.Model):
_inherit = "stock.location.tray.type"
location_storage_type_ids = fields.Many2many(
comodel_name="stock.location.storage.type",
help="Location storage types applied on the location using " "this tray type.",
)
def write(self, values):
res = super().write(values)
if values.get("location_storage_type_ids"):
self._sync_location_storage_type_ids()
return res
def _sync_location_storage_type_ids(self):
for tray_type in self:
tray_type.location_ids.with_context(_sync_tray_type=True).write(
{
"location_storage_type_ids": [
(6, 0, tray_type.location_storage_type_ids.ids)
]
}
)

View File

@@ -0,0 +1,18 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import models
class StockQuant(models.Model):
_inherit = "stock.quant"
def _check_storage_type(self):
# disable the checks when placing goods in vertical lift cells:
# we still want to benefit of the storage type rules to select
# a destination, but we want to allow selecting a different tray
# type
self = self.filtered(
lambda quant: quant.location_id.vertical_lift_kind != "cell"
)
super()._check_storage_type()

View File

@@ -0,0 +1,123 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, models
class VerticalLiftOperationPut(models.Model):
_inherit = "vertical.lift.operation.put"
# In the base module, when a good is scanned for a put-away, the user must
# then scan a tray type, which will be used to find an available cell of
# this type. When we use storage types (OCA/wms::stock_storage_type), we
# shouldn't need to scan a tray type if we already have a storage type,
# that could be used to find an available cell location for this type
# (applying all the possible restrictions from storage type).
def _transitions(self):
transitions = super()._transitions()
updated_transitions = []
for transition in transitions:
states = (transition.current_state, transition.next_state)
if states == ("scan_tray_type", "save"):
# insert new transitions just before the normal transition
# scanning the tray type, that will bypass it when we have
# a storage type
updated_transitions.append(
self.Transition(
"scan_tray_type",
"save",
lambda self: self._has_storage_type()
and self._putaway_with_storage_type(),
# this is the trick that makes the transition applies
# its function and directly jumps to save
direct_eval=True,
)
)
updated_transitions.append(
self.Transition(
"scan_tray_type",
"scan_source",
# the transition above returned False because it could
# not find a free space, in that case, abort the
# put-away for this line in this shuttle
lambda self: self._has_storage_type()
and self._put_away_with_storage_type_failed()
and self.clear_current_move_line(),
# this is the trick that makes the transition applies
# its function and directly jumps to save
direct_eval=True,
)
)
# if none of the 2 transitions above is applied (because
# self._has_storage_type() is False), the state remains
# `scan_tray_type`, for the base transition doesn't have
# `direct_eval=True`
updated_transitions.append(transition)
return tuple(updated_transitions)
def _has_storage_type_domain(self):
move_line = self.current_move_line_id
package_storage_type = move_line.package_id.package_storage_type_id
# When a put-away is done based on the package's storage type and no
# destination is found, we can have 2 reasons:
#
# 1. No location is available according to the storage types rules,
# for instance because they are full
# 2. The storage type of the package doesn't have any location
# configured in the shuttle's locations
#
# We want to differentiate the 2 cases and handle them differently.
# For 2. we consider the storage type is not meant to be in any
# shuttle's tray but the user still tries: act the same way as if we
# had no storage type at all, the user has to scan the tray type.
# For 1. we try to find a destination location and if none is found,
# a notification indicates that the shuttle is full.
# In any case, the user should always be able to scan a different
# tray type.
return [
("id", "!=", self.location_id.id),
("id", "child_of", self.location_id.id),
(
"allowed_location_storage_type_ids",
"in",
package_storage_type.location_storage_type_ids.ids,
),
]
def _has_storage_type(self):
domain = self._has_storage_type_domain()
# we don't care about order and count, only if we have at least one
# configured location for the storage type: sorting by id and limit by
# 1 will give the best explain plan
compatible_locations = self.env["stock.location"].search(
domain, limit=1, order="id"
)
return bool(compatible_locations)
def _putaway_with_storage_type(self):
move_line = self.current_move_line_id
# Trigger the put-away application to place it somewhere inside
# the current shuttle's location.
new_destination = move_line.location_dest_id._get_pack_putaway_strategy(
self.location_id, move_line.package_id.quant_ids, move_line.product_id
)
if new_destination and new_destination.vertical_lift_kind == "cell":
move_line.location_dest_id = new_destination
move_line.package_level_id.location_dest_id = new_destination
self.fetch_tray()
return True
# no destination found: all the trays for this tray type are full, or rejected
# by the location storage type rules
return False
def _put_away_with_storage_type_failed(self):
move_line = self.current_move_line_id
storage_type = move_line.package_id.package_storage_type_id
self.env.user.notify_warning(
_("No free space found for storage type '{}' in shuttle '{}'").format(
storage_type.name, self.name
)
)
return True

View File

@@ -0,0 +1 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>

View File

@@ -0,0 +1,14 @@
Compatibility layer between Stock Vertical Lift and Putaway Storage Types (OCA/wms).
In the vertical lift's Putaway screen, when a good is scanned for a putaway, the
user has to scan the tray type of the corresponding size, so an empty place in a
matching tray is found. When we use storage types, we should know what tray is
compatible with the storage type.
Changes with this module:
* The storage types of trays cannot be selected in the locations form, they have
to be set in the Tray types.
* In the lift put-away screen, when a package has a storage type, the user isn't
asked to scan a tray type, instead, the putaway of the Package Storage Type is
applied.

View File

@@ -0,0 +1,3 @@
from . import test_put
from . import test_stock_location
from . import test_tray_type

View File

@@ -0,0 +1,27 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.stock_vertical_lift.tests.common import VerticalLiftCase
class TrayTypeCommonCase(VerticalLiftCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TrayType = cls.env["stock.location.tray.type"]
cls.location_2b = cls.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2b"
)
cls.location_2d = cls.env.ref(
"stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2d"
)
LocStorageType = cls.env["stock.location.storage.type"]
cls.location_storage_type_buffer = LocStorageType.create(
{"name": "VLift Buffer"}
)
cls.location_storage_type_small_8x = LocStorageType.create(
{"name": "Small 8x", "only_empty": True}
)
cls.storage_types = (
cls.location_storage_type_small_8x | cls.location_storage_type_buffer
)

View File

@@ -0,0 +1,165 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.stock_vertical_lift.tests.common import VerticalLiftCase
class TestPut(VerticalLiftCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.wh = cls.env.ref("stock.warehouse0")
cls.wh.wh_input_stock_loc_id.active = True
cls.wh.int_type_id.active = True
# used on the vertical lift top level
LocStorageType = cls.env["stock.location.storage.type"]
cls.location_storage_type_buffer = LocStorageType.create(
{"name": "VLift Buffer"}
)
cls.location_storage_type_small_8x = LocStorageType.create(
{"name": "Small 8x", "only_empty": True}
)
PackageStorageType = cls.env["stock.package.storage.type"]
# package storage type used only to putaway a package temporarily in
# the vertical lift view location before being put in a shuttle, which
# will not be configured in any shuttle's locations, we'll use when
# the operator knows that a good has to go in a shuttle, but doesn't
# know yet in which tray type: when the first putaway is done, the good
# stays in the vertical lift view (above the shuttles), then, when the
# user scans the package in a shuttle, they have to scan a tray type.
cls.package_storage_type_buffer = PackageStorageType.create(
{
"name": "VLift Box",
"location_storage_type_ids": [
(4, cls.location_storage_type_buffer.id),
],
}
)
# storage type used for Tray 1A, user won't have to scan a tray type
# when this storage type is already set on the package
cls.package_storage_type_small_8x = PackageStorageType.create(
{
"name": "Small 8x",
"location_storage_type_ids": [
(4, cls.location_storage_type_small_8x.id),
(4, cls.location_storage_type_buffer.id),
],
}
)
cls.location_shuttle1 = cls.shuttle.location_id
cls.vertical_lift_loc.location_storage_type_ids = (
cls.location_storage_type_buffer
)
cls.vertical_lift_loc.pack_putaway_strategy = "none"
cls.location_shuttle1.location_storage_type_ids = (
cls.location_storage_type_small_8x
)
cls.location_shuttle1.pack_putaway_strategy = "ordered_locations"
cls.env["stock.storage.location.sequence"].create(
{
"package_storage_type_id": cls.package_storage_type_small_8x.id,
"sequence": 1,
"location_id": cls.vertical_lift_loc.id,
}
)
cls.env["stock.storage.location.sequence"].create(
{
"package_storage_type_id": cls.package_storage_type_small_8x.id,
"sequence": 2,
"location_id": cls.location_shuttle1.id,
}
)
cls.package = cls.env["stock.quant.package"].create({})
cls._update_qty_in_location(
cls.wh.wh_input_stock_loc_id, cls.product_socks, 10, package=cls.package
)
cls.int_picking = cls._create_simple_picking_int(
cls.product_socks, 10, cls.vertical_lift_loc
)
cls.int_picking.action_confirm()
cls.int_picking.action_assign()
@classmethod
def _create_simple_picking_int(cls, product, quantity, dest_location):
return cls.env["stock.picking"].create(
{
"picking_type_id": cls.wh.int_type_id.id,
"location_id": cls.wh.wh_input_stock_loc_id.id,
"location_dest_id": dest_location.id,
"move_lines": [
(
0,
0,
{
"name": product.name,
"product_id": product.id,
"product_uom": product.uom_id.id,
"product_uom_qty": quantity,
"picking_type_id": cls.wh.int_type_id.id,
"location_id": cls.wh.wh_input_stock_loc_id.id,
"location_dest_id": dest_location.id,
},
)
],
}
)
def test_storage_type_put_away(self):
self.package.package_storage_type_id = self.package_storage_type_small_8x
move_line = self.int_picking.move_line_ids
self.assertEqual(move_line.location_dest_id, self.vertical_lift_loc)
self.assertEqual(
move_line.package_level_id.location_dest_id, self.vertical_lift_loc
)
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")
operation.on_barcode_scanned(self.package.name)
self.assertEqual(operation.current_move_line_id, move_line)
# the dest location was Vertical Lift, it has been change to Vertical
# Lift/Shuttle 1, and the computation from there took the first cell
# available, we should be the pos x1 and y1 in the tray A.
self.assertEqual(move_line.location_dest_id, self.location_1a_x1y1)
self.assertEqual(
move_line.package_level_id.location_dest_id, self.location_1a_x1y1
)
# the state goes straight to "save", as we don't need to scan the tray type
# when a putaway is available
self.assertEqual(operation.state, "save")
def test_storage_type_not_configured(self):
# if we do have a storage type, but the storage type is not used in the
# shuttle (although it may be used on the lift view location, so we can have an
# initial putaway rule that puts the package in the lift view location first),
# we have to ask to scan a tray type
self.package.package_storage_type_id = self.package_storage_type_buffer
move_line = self.int_picking.move_line_ids
self.assertEqual(move_line.location_dest_id, self.vertical_lift_loc)
self.assertEqual(
move_line.package_level_id.location_dest_id, self.vertical_lift_loc
)
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")
operation.on_barcode_scanned(self.package.name)
self.assertEqual(operation.current_move_line_id, move_line)
# it stays here, as the put-away has no rule to put the "buffer box" in
# any location
self.assertEqual(move_line.location_dest_id, self.vertical_lift_loc)
# and the user has to scan a tray type (so we are back to the normal
# flow, tested in stock_vertical_lift)
self.assertEqual(operation.state, "scan_tray_type")

View File

@@ -0,0 +1,90 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import exceptions
from .common import TrayTypeCommonCase
class TestTrayTypeLocation(TrayTypeCommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.tray_type = cls.env.ref(
"stock_location_tray.stock_location_tray_type_small_8x"
)
cls.tray_type.write(
{"location_storage_type_ids": [(6, 0, cls.storage_types.ids)]}
)
cls.locations = cls.location_2a | cls.location_2b
def test_location_create_sync(self):
locations = self.env["stock.location"].create(
[
{
"name": "tray test 1",
"location_id": self.shuttle.location_id.id,
"usage": "internal",
"tray_type_id": self.tray_type.id,
},
{
"name": "tray test 2",
"location_id": self.shuttle.location_id.id,
"usage": "internal",
"tray_type_id": self.tray_type.id,
},
]
)
self.assertEqual(locations[0].location_storage_type_ids, self.storage_types)
self.assertEqual(locations[1].location_storage_type_ids, self.storage_types)
def test_location_write_sync(self):
self.locations.tray_type_id = self.tray_type
self.assertEqual(self.location_2a.location_storage_type_ids, self.storage_types)
self.assertEqual(self.location_2b.location_storage_type_ids, self.storage_types)
def test_location_create_error(self):
with self.assertRaisesRegex(exceptions.UserError, "Error creating.*"):
self.env["stock.location"].create(
[
{
"name": "tray test 1",
"location_id": self.shuttle.location_id.id,
"usage": "internal",
"tray_type_id": self.tray_type.id,
"location_storage_type_ids": [
(6, 0, self.location_storage_type_buffer.ids)
],
},
{
"name": "tray test 2",
"location_id": self.shuttle.location_id.id,
"usage": "internal",
"tray_type_id": self.tray_type.id,
"location_storage_type_ids": [
(6, 0, self.location_storage_type_buffer.ids)
],
},
]
)
def test_location_write_both_fields_error(self):
with self.assertRaisesRegex(exceptions.UserError, "Error updating.*"):
self.locations.write(
{
"tray_type_id": self.tray_type.id,
"location_storage_type_ids": [
(6, 0, self.location_storage_type_buffer.ids)
],
}
)
def test_location_write_storage_type_error(self):
with self.assertRaisesRegex(exceptions.UserError, "Error updating.*"):
self.locations.write(
{
"location_storage_type_ids": [
(6, 0, self.location_storage_type_buffer.ids)
],
}
)

Some files were not shown because too many files have changed in this diff Show More