[REF] stock_picking_batch_collect: rename and drop the aplicoop dependency

"custom" said nothing about what the module does. It is really about collecting
goods into baskets: the extra detailed-operation columns, the is_collected flag,
the Product Summary tab, the per-company validation restrictions and the
Basket Assembly operator view all serve that one job. Rename it accordingly.

Invert the dependency while at it. A generic warehouse addon was dragging in an
entire eCommerce application, and the whole coupling was a single field:
stock.move.line.home_delivery, related to picking_id.home_delivery. Everything
else was already duck-typed. website_sale_aplicoop now depends on this module
and injects its own consumer group columns into these views.

This removes duplicated logic rather than relocating it: stock.picking
.batch_consumer_group_id re-derived from sale_id a value aplicoop already stored
as stock.picking.consumer_group_id, and the duplicate carried no @api.depends,
so it never recomputed reliably. The batch transfers list now shows the stored
field, which is sortable and groupable.

The two aplicoop tests that probed information_schema for the res_company
batch_* columns can drop that guard: a real dependency guarantees them.

Renaming an addon is not something a migrations/ script can do, since a renamed
addon is a brand new module to Odoo and its migration scripts never run. A
pre_init_hook does it instead: it fires on install after the Python is imported
but before registry.load(), which is the window where remapping ir_model_data
makes Odoo reuse the existing tables and columns. is_collected, the summary line
table and the company settings all survive untouched.

Two details the hook has to get right:

- ir_model_constraint.module and ir_model_relation.module are integer FKs with
  ON DELETE CASCADE, so they must be repointed before the old module row is
  deleted or the bookkeeping goes with it.
- Deleting an ir_model_data row does not cascade to the record it points at.
  Artifacts handed over to aplicoop only need the xmlid dropped, but artifacts
  that disappear need the record deleted too, or the field survives as an orphan
  manual field and the view as a custom view referencing it.

Verified against a restored copy of the dev database: 50 xmlids moved, 19 summary
lines and 5 collected move lines preserved, no orphans, both test suites green,
and the module installs cleanly on a database without website_sale_aplicoop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-27 14:26:47 +02:00
parent 704f0def1b
commit ef1283be7c
42 changed files with 926 additions and 709 deletions

View file

@ -0,0 +1,127 @@
===========================
Stock Picking Batch Collect
===========================
Visión general
==============
Este módulo convierte un lote de picking en una hoja de trabajo para el operario
que monta las cestas: amplía las operaciones detalladas, añade un resumen por
producto y una vista a pantalla completa para el almacén.
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. Las
líneas del lote se ordenan por categoría, producto y partner.
- ``product_default_code`` (Referencia interna) como columna opcional.
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
ha recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
- Botón **Basket Assembly**: una vista de lista a pantalla completa, con
cantidades grandes y controles táctiles, pensada para tablet en el almacén.
- Restricciones de validación configurables por compañía: el lote no se puede
validar hasta que las líneas estén marcadas como recogidas.
Las columnas se añaden con ``optional="show"`` en la vista de líneas del lote
(salvo ``product_default_code``, oculta por defecto), de modo que el usuario
puede desactivarlas desde el selector de columnas sin recargar la vista.
Si además está instalado ``website_sale_aplicoop``, ese módulo añade a estas
mismas vistas las columnas de grupo de consumo (**Consumer Group** y **Home
Delivery**). Este módulo no depende de él: funciona igual sin la parte de
eCommerce.
Instalación
===========
Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_collect
Este módulo sustituye a ``stock_picking_batch_custom``. Al instalarlo sobre una
base de datos que tuviera el módulo antiguo, un ``pre_init_hook`` adopta sus
datos (ver ``hooks.py``): se conservan las líneas recogidas, el resumen por
producto y la configuración por compañía.
Configuración
=============
Restricciones de validación
---------------------------
En **Inventory > Configuration > Settings**, bloque *Operations*, hay dos
ajustes por compañía:
- **Restricciones del resumen de productos**: exige que las líneas de la
pestaña *Product Summary* estén marcadas como recogidas para validar el lote.
Desactivado por defecto (el check es sólo informativo).
- **Restricciones de operaciones detalladas**: exige que las líneas de la
pestaña *Detailed Operations* estén marcadas como recogidas.
**Activado por defecto**: recién instalado el módulo, un lote no se puede
validar hasta marcar las líneas.
Cada restricción tiene un alcance:
- *Sólo procesadas*: se comprueban únicamente las líneas con cantidad hecha
mayor que cero (por defecto).
- *Todas*: se comprueban todas las líneas del lote, procesadas o no.
Columnas
--------
Las columnas están visibles por defecto. Para ajustarlas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar o desactivar *Partner*,
*Product Category*, *Product Code* o *Collected* según necesidad.
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para ajustar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Con el lote **en progreso**, el botón **Basket Assembly** abre la vista de
operario: la misma lista de líneas a pantalla completa, con la cantidad en
grande y el check *Collected* como interruptor táctil. Está pensada para
trabajar desde una tablet mientras se montan las cestas.
5. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
6. La cantidad que teclea el operario (el peso real de la balanza) queda fijada:
se marca como *Picked* y ni el planificador ni la validación de otros
albaranes vuelven a modificarla. Marcar **Collected** protege igualmente la
cantidad de la línea. Ver ``stock_move_manual_quantity``.
7. Al validar el lote, según la configuración de la compañía, se comprueba que
las líneas estén recogidas y se avisa con la lista de productos pendientes.
La comprobación se ejecuta después de resolver el backorder, de modo que
sólo bloquea sobre las líneas que realmente se van a procesar.
Contribuidores
==============
* Criptomart
Créditos
========
Autor
-----
* Criptomart
Financiador
-----------
* Elika Bilbo

View file

@ -0,0 +1,2 @@
from . import models # noqa: F401
from .hooks import pre_init_hook # noqa: F401

View file

@ -0,0 +1,31 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Stock Picking Batch Collect",
"version": "18.0.2.0.0",
"category": "Warehouse",
"summary": "Collect batch operations: operator view, extra columns and product summary",
"author": "Odoo Community Association (OCA), Criptomart",
"maintainers": ["Criptomart"],
"website": "https://github.com/Criptomart",
"license": "AGPL-3",
"depends": [
# A quantity typed by the operator is the weighed one: it must not be
# re-reserved by the scheduler.
"stock_move_manual_quantity",
"stock_picking_batch",
],
"data": [
"security/ir.model.access.csv",
"views/res_config_settings_views.xml",
"views/stock_picking_batch_views.xml",
],
"assets": {
"web.assets_backend": [
"stock_picking_batch_collect/static/src/css/stock_picking_batch.css",
],
},
# Adopts the data of the former `stock_picking_batch_custom` when upgrading
# a database where that module was installed. See `hooks.py`.
"pre_init_hook": "pre_init_hook",
}

View file

@ -0,0 +1,117 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
_logger = logging.getLogger(__name__)
OLD_MODULE = "stock_picking_batch_custom"
NEW_MODULE = "stock_picking_batch_collect"
# Fields handed over to `website_sale_aplicoop`, which declares the very same
# ones. Dropping just the xmlid is enough and is what we want: Odoo matches the
# existing row again by (model, name) and re-registers it under aplicoop's
# ownership later in the same run (aplicoop depends on us, so it loads after
# us). Both are non-stored, so no column is touched.
HANDED_OVER = (
"field_stock_move_line__home_delivery",
"field_stock_move_line__consumer_group_id",
)
# Artifacts that disappear for good, because aplicoop already stores the same
# value as `stock.picking.consumer_group_id`. Here the record itself has to go
# too, not only the xmlid: deleting an `ir_model_data` row does not cascade, so
# the field would survive as an orphan manual field and the view as a custom
# view still pointing at it. Mapped as table -> xmlids.
DROPPED = {
"ir_model_fields": ("field_stock_picking__batch_consumer_group_id",),
"ir_ui_view": ("view_stock_picking_batch_picking_tree_consumer_group",),
}
def pre_init_hook(env):
"""Adopt the database records of the former ``stock_picking_batch_custom``.
A renamed addon is a brand new module for Odoo, so a versioned
``migrations/`` script would never run. This hook does instead, on install,
after the Python is imported but before ``registry.load()`` and
``init_models()``: remapping ``ir_model_data`` in that window makes Odoo
reuse the existing tables and columns instead of creating new ones, so
``stock_move_line.is_collected``, the ``stock_picking_batch_summary_line``
table and the ``res_company.batch_*`` settings all survive untouched.
A no-op on a database where the old module was never installed.
"""
cr = env.cr
cr.execute("SELECT id FROM ir_module_module WHERE name = %s", (OLD_MODULE,))
old = cr.fetchone()
if not old:
return
old_id = old[0]
cr.execute("SELECT id FROM ir_module_module WHERE name = %s", (NEW_MODULE,))
new = cr.fetchone()
if not new:
# Should not happen: the module list is refreshed before installing.
_logger.warning("%s: no module row to adopt %s into", NEW_MODULE, OLD_MODULE)
return
new_id = new[0]
_logger.info(
"Adopting %s (id=%s) as %s (id=%s)", OLD_MODULE, old_id, NEW_MODULE, new_id
)
# 1. Hand every xmlid over to the new module name.
cr.execute(
"UPDATE ir_model_data SET module = %s WHERE module = %s",
(NEW_MODULE, OLD_MODULE),
)
_logger.info("Moved %s xmlids to %s", cr.rowcount, NEW_MODULE)
# 2. Give back what is no longer ours, so aplicoop can claim it.
cr.execute(
"DELETE FROM ir_model_data WHERE module = %s AND name IN %s",
(NEW_MODULE, HANDED_OVER),
)
# 3. Drop for good what neither module defines any more, record included.
for table, xmlids in DROPPED.items():
cr.execute(
"DELETE FROM %s WHERE id IN ("
" SELECT res_id FROM ir_model_data"
" WHERE module = %%s AND name IN %%s"
")" % table,
(NEW_MODULE, xmlids),
)
if cr.rowcount:
_logger.info("Dropped %s obsolete row(s) from %s", cr.rowcount, table)
cr.execute(
"DELETE FROM ir_model_data WHERE module = %s AND name IN %s",
(NEW_MODULE, tuple(name for names in DROPPED.values() for name in names)),
)
# 4. `ir_model_constraint.module` and `ir_model_relation.module` are integer
# FKs to `ir_module_module` with ON DELETE CASCADE: they must be
# repointed *before* the old row goes away, or the bookkeeping for our
# SQL constraints is silently destroyed with it.
cr.execute(
"UPDATE ir_model_constraint SET module = %s WHERE module = %s",
(new_id, old_id),
)
cr.execute(
"UPDATE ir_model_relation SET module = %s WHERE module = %s",
(new_id, old_id),
)
# 5. Drop the dependency rows, both ours and any pointing at the old name.
cr.execute(
"DELETE FROM ir_module_module_dependency WHERE module_id = %s OR name = %s",
(old_id, OLD_MODULE),
)
# 6. Finally the module row itself. We delete the *old* one and keep the
# new one: the loader already holds the new row's id in memory, so
# renaming the old row in place would leave Odoo writing to a record
# that no longer exists.
cr.execute("DELETE FROM ir_module_module WHERE id = %s", (old_id,))

View file

@ -0,0 +1,235 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * stock_picking_batch_collect
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-21 13:18+0000\n"
"PO-Revision-Date: 2026-05-21 13:18+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__all
msgid "All detailed lines"
msgstr "Todas las líneas detalladas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__all
msgid "All summary products"
msgstr "Todos los productos del resumen"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_backorder_confirmation
msgid "Backorder Confirmation"
msgstr "Confirmación de entrega parcial"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__batch_id
msgid "Batch"
msgstr "Lote"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking__batch_consumer_group_id
msgid "Batch Consumer Group"
msgstr "Grupo de consumidores del lote"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Detailed Operations Restriction"
msgstr "Restricción de operaciones detalladas del lote"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch_summary_line
msgid "Batch Product Summary Line"
msgstr "Línea de resumen de productos del lote"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Product Summary Restriction"
msgstr "Restricción del resumen de productos del lote"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch
msgid "Batch Transfer"
msgstr "Traslado por lote"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected
msgid "Collected"
msgstr "Recogido"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_company
msgid "Companies"
msgstr "Compañías"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_config_settings
msgid "Config Settings"
msgstr "Ajustes de configuración"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Operaciones Detalladas."
msgstr ""
"Configuración de restricciones para la pestaña Operaciones Detalladas."
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Product Summary."
msgstr "Configuración de restricciones para la pestaña Resumen de productos."
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__consumer_group_id
msgid "Consumer Group"
msgstr "Grupo de consumidores"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_uid
msgid "Created by"
msgstr "Creado por"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_date
msgid "Created on"
msgstr "Creado el"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_demanded
msgid "Demanded Quantity"
msgstr "Cantidad demandada"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_scope
msgid "Detailed Operations Restriction Scope"
msgstr "Ámbito de restricción de operaciones detalladas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__display_name
msgid "Display Name"
msgstr "Nombre mostrado"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_done
msgid "Done Quantity"
msgstr "Cantidad hecha"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_enabled
msgid "Enforce Detailed Operations Restriction"
msgstr "Aplicar restricción de operaciones detalladas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_enabled
msgid "Enforce Product Summary Restriction"
msgstr "Aplicar restricción del resumen de productos"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Home Delivery"
msgstr "Entrega a domicilio"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__id
msgid "ID"
msgstr "ID"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_uid
msgid "Last Updated by"
msgstr "Última actualización por"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_date
msgid "Last Updated on"
msgstr "Última actualización el"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__processed
msgid "Only processed lines"
msgstr "Solo líneas procesadas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__processed
msgid "Only processed products"
msgstr "Solo productos procesados"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_pending
msgid "Pending Quantity"
msgstr "Cantidad pendiente"
#. module: stock_picking_batch_collect
#. odoo-python
#: code:addons/stock_picking_batch_collect/models/stock_picking_batch.py:0
msgid "Pending products: %(products)s"
msgstr "Productos pendientes: %(products)s"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_id
msgid "Product"
msgstr "Producto"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_categ_id
msgid "Product Category"
msgstr "Categoría de producto"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__product_categ_id
msgid "Product Category (Batch)"
msgstr "Categoría de producto (lote)"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
msgstr "Movimientos de producto (línea de movimiento de stock)"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch__summary_line_ids
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Product Summary"
msgstr "Resumen de productos"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_scope
msgid "Product Summary Restriction Scope"
msgstr "Ámbito de restricción del resumen de productos"
#. module: stock_picking_batch_collect
#: model:ir.model.constraint,message:stock_picking_batch_collect.constraint_stock_picking_batch_summary_line_product_required
msgid "Product is required for summary lines."
msgstr "El producto es obligatorio en las líneas de resumen."
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking
msgid "Transfer"
msgstr "Traslado"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_uom_id
msgid "Unit of Measure"
msgstr "Unidad de medida"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,help:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Whether this picking includes home delivery (from sale order)"
msgstr "Si este albarán incluye entrega a domicilio (del pedido de venta)"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly"
msgstr "Montaje de Cestas"

View file

@ -0,0 +1,235 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * stock_picking_batch_collect
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-21 13:21+0000\n"
"PO-Revision-Date: 2026-05-21 13:21+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__all
msgid "All detailed lines"
msgstr "Lerro xehatu guztiak"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__all
msgid "All summary products"
msgstr "Laburpeneko produktu guztiak"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_backorder_confirmation
msgid "Backorder Confirmation"
msgstr "Eskaeraren informazioa"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__batch_id
msgid "Batch"
msgstr "Lotea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking__batch_consumer_group_id
msgid "Batch Consumer Group"
msgstr "Loteko kontsumitzaile taldea"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Detailed Operations Restriction"
msgstr "Loteko eragiketa xehatuen murrizketa"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch_summary_line
msgid "Batch Product Summary Line"
msgstr "Loteko produktuen laburpen-lerroa"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Batch Product Summary Restriction"
msgstr "Loteko produktuen laburpenaren murrizketa"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking_batch
msgid "Batch Transfer"
msgstr "Lote-transferentzia"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected
msgid "Collected"
msgstr "Jasota"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_company
msgid "Companies"
msgstr "Enpresak"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_res_config_settings
msgid "Config Settings"
msgstr "Konfigurazio ezarpenak"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Operaciones Detalladas."
msgstr "Eragiketa xehatuak fitxarako murrizketen konfigurazioa."
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.res_config_settings_view_form_inherit_batch_custom
msgid "Configuración de restricciones para la pestaña Product Summary."
msgstr "Produktuen laburpena fitxarako murrizketen konfigurazioa."
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__consumer_group_id
msgid "Consumer Group"
msgstr "Kontsumitzaile taldea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_uid
msgid "Created by"
msgstr "Nork sortua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__create_date
msgid "Created on"
msgstr "Noiz sortua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_demanded
msgid "Demanded Quantity"
msgstr "Eskatutako kantitatea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_scope
msgid "Detailed Operations Restriction Scope"
msgstr "Eragiketa xehatuen murrizketaren irismena"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__display_name
msgid "Display Name"
msgstr "Bistaratzeko izena"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_done
msgid "Done Quantity"
msgstr "Egindako kantitatea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_detailed_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_detailed_restriction_enabled
msgid "Enforce Detailed Operations Restriction"
msgstr "Eragiketa xehatuen murrizketa aplikatu"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_enabled
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_enabled
msgid "Enforce Product Summary Restriction"
msgstr "Produktuen laburpenaren murrizketa aplikatu"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Home Delivery"
msgstr "Etxez etxeko entrega"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__id
msgid "ID"
msgstr "ID"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_uid
msgid "Last Updated by"
msgstr "Azken eguneratzailea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__write_date
msgid "Last Updated on"
msgstr "Azken eguneratzea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_detailed_restriction_scope__processed
msgid "Only processed lines"
msgstr "Prozesatutako lerroak soilik"
#. module: stock_picking_batch_collect
#: model:ir.model.fields.selection,name:stock_picking_batch_collect.selection__res_company__batch_summary_restriction_scope__processed
msgid "Only processed products"
msgstr "Prozesatutako produktuak soilik"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_pending
msgid "Pending Quantity"
msgstr "Zain dagoen kantitatea"
#. module: stock_picking_batch_collect
#. odoo-python
#: code:addons/stock_picking_batch_collect/models/stock_picking_batch.py:0
msgid "Pending products: %(products)s"
msgstr "Zain dauden produktuak: %(products)s"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_id
msgid "Product"
msgstr "Produktua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_categ_id
msgid "Product Category"
msgstr "Produktu-kategoria"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__product_categ_id
msgid "Product Category (Batch)"
msgstr "Produktu-kategoria (lotea)"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_move_line
msgid "Product Moves (Stock Move Line)"
msgstr "Produktuen mugimenduak (stock mugimenduaren lerroa)"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch__summary_line_ids
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Product Summary"
msgstr "Produktuen laburpena"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_company__batch_summary_restriction_scope
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_res_config_settings__batch_summary_restriction_scope
msgid "Product Summary Restriction Scope"
msgstr "Produktuen laburpenaren murrizketaren irismena"
#. module: stock_picking_batch_collect
#: model:ir.model.constraint,message:stock_picking_batch_collect.constraint_stock_picking_batch_summary_line_product_required
msgid "Product is required for summary lines."
msgstr "Produktua derrigorrezkoa da laburpen-lerroetan."
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_stock_picking
msgid "Transfer"
msgstr "Transferentzia"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__product_uom_id
msgid "Unit of Measure"
msgstr "Neurri-unitatea"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,help:stock_picking_batch_collect.field_stock_move_line__home_delivery
msgid "Whether this picking includes home delivery (from sale order)"
msgstr ""
"Albaran honek etxez etxeko entrega barne hartzen duen (salmenta-eskaeratik)"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly"
msgstr "Saskien Prestaketa"

View file

@ -0,0 +1,6 @@
from . import res_company # noqa: F401
from . import res_config_settings # noqa: F401
from . import stock_backorder_confirmation # noqa: F401
from . import stock_move_line # noqa: F401
from . import stock_picking # noqa: F401
from . import stock_picking_batch # noqa: F401

View file

@ -0,0 +1,37 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class ResCompany(models.Model):
_inherit = "res.company"
batch_summary_restriction_enabled = fields.Boolean(
string="Enforce Product Summary Restriction",
default=False,
)
batch_summary_restriction_scope = fields.Selection(
selection=[
("processed", "Only processed products"),
("all", "All summary products"),
],
string="Product Summary Restriction Scope",
default="processed",
required=True,
)
batch_detailed_restriction_enabled = fields.Boolean(
string="Enforce Detailed Operations Restriction",
default=True,
)
batch_detailed_restriction_scope = fields.Selection(
selection=[
("processed", "Only processed lines"),
("all", "All detailed lines"),
],
string="Detailed Operations Restriction Scope",
default="processed",
required=True,
)

View file

@ -0,0 +1,27 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
batch_summary_restriction_enabled = fields.Boolean(
related="company_id.batch_summary_restriction_enabled",
readonly=False,
)
batch_summary_restriction_scope = fields.Selection(
related="company_id.batch_summary_restriction_scope",
readonly=False,
)
batch_detailed_restriction_enabled = fields.Boolean(
related="company_id.batch_detailed_restriction_enabled",
readonly=False,
)
batch_detailed_restriction_scope = fields.Selection(
related="company_id.batch_detailed_restriction_scope",
readonly=False,
)

View file

@ -0,0 +1,23 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class StockBackorderConfirmation(models.TransientModel):
_inherit = "stock.backorder.confirmation"
def _check_batch_summary_lines(self):
pickings = self.env["stock.picking"].browse(
self.env.context.get("button_validate_picking_ids", [])
)
for batch in pickings.batch_id:
batch._check_all_products_collected(pickings)
def process(self):
self._check_batch_summary_lines()
return super().process()
def process_cancel_backorder(self):
self._check_batch_summary_lines()
return super().process_cancel_backorder()

View file

@ -0,0 +1,51 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
_order = "product_categ_id, product_id, picking_partner_id, id"
product_categ_id = fields.Many2one(
comodel_name="product.category",
string="Product Category (Batch)",
related="product_id.categ_id",
store=True,
readonly=True,
)
product_default_code = fields.Char(
string="Product Code",
related="product_id.default_code",
readonly=True,
)
picking_partner_id = fields.Many2one(
related="picking_id.partner_id",
store=True,
readonly=True,
)
is_collected = fields.Boolean(
string="Collected",
default=False,
copy=False,
)
def write(self, vals):
res = super().write(vals)
if vals.get("is_collected"):
# Collecting a line is the operator saying the goods are in the
# basket: its quantity must survive the reservation engine even if
# it was never retyped. The demand is left alone, only a quantity
# typed by hand adjusts it.
lines = self.filtered(
lambda line: not line.picked and line.state not in ("done", "cancel")
)
if lines:
lines.picked = True
lines._freeze_manual_quantity(align_demand=False)
return res

View file

@ -0,0 +1,39 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class StockPicking(models.Model):
_inherit = "stock.picking"
def _pre_action_done_hook(self):
"""Run collected checks only after Odoo resolves backorders.
This keeps the product summary checkbox informative during the initial
batch validation click, but still enforces it once the user confirms the
actual picking flow that will be processed.
"""
res = super()._pre_action_done_hook()
if res is not True:
return res
self._check_batch_collected_products()
return res
def _check_batch_collected_products(self):
for batch in self.batch_id:
batch_pickings = self.filtered(
lambda picking, batch=batch: picking.batch_id == batch
)
processed_move_lines = batch_pickings.move_line_ids.filtered(
lambda line: (
line.move_id.state not in ("cancel", "done")
and line.product_id
and line.move_id.quantity > 0
)
)
if not processed_move_lines:
continue
batch._check_all_products_collected(batch_pickings)

View file

@ -0,0 +1,311 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import fields
from odoo import models
from odoo.exceptions import UserError
class StockPickingBatch(models.Model):
_inherit = "stock.picking.batch"
summary_line_ids = fields.One2many(
comodel_name="stock.picking.batch.summary.line",
inverse_name="batch_id",
string="Product Summary",
compute="_compute_summary_line_ids",
store=True,
readonly=True,
)
@api.depends(
"move_line_ids.move_id.product_id",
"move_line_ids.move_id.product_uom_qty",
"move_line_ids.move_id.quantity",
"move_line_ids.move_id.product_uom",
"move_line_ids.move_id.state",
)
def _compute_summary_line_ids(self):
"""Aggregate move quantities per product and keep collected flag.
- Demand: move.product_uom_qty converted to product UoM.
- Done: move.quantity converted to product UoM.
- Pending: demand - done.
- Keep is_collected value if product already present.
- Skip cancelled moves.
"""
for batch in self:
# Base: limpiar si no hay líneas
if not batch.move_line_ids:
batch.summary_line_ids = [fields.Command.clear()]
continue
previous_collected = {
line.product_id.id: line.is_collected
for line in batch.summary_line_ids
if line.product_id
}
# Use regular dict to avoid accidental creation of empty entries
aggregates = {}
# Demand per move (count once per move)
for move in batch.move_ids:
if move.state == "cancel" or not move.product_id:
continue
product = move.product_id
product_uom = product.uom_id
demand = move.product_uom._compute_quantity(
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
)
if product.id not in aggregates:
aggregates[product.id] = {
"product_id": product.id,
"product_uom_id": product_uom.id,
"qty_demanded": 0.0,
"qty_done": 0.0,
}
aggregates[product.id]["qty_demanded"] += demand
# Done per move line
for line in batch.move_line_ids:
move = line.move_id
if move and move.state == "cancel":
continue
product = line.product_id
if not product:
continue
product_uom = product.uom_id
done = line.product_uom_id._compute_quantity(
line.quantity, product_uom, rounding_method="HALF-UP"
)
if product.id not in aggregates:
aggregates[product.id] = {
"product_id": product.id,
"product_uom_id": product_uom.id,
"qty_demanded": 0.0,
"qty_done": 0.0,
}
aggregates[product.id]["qty_done"] += done
commands = []
products_in_totals = set()
existing_by_product = {
line.product_id.id: line for line in batch.summary_line_ids
}
for product_id, values in aggregates.items():
# Double-check: skip if product_id is not valid
if not product_id or not values.get("product_id"):
continue
products_in_totals.add(product_id)
existing_line = existing_by_product.get(product_id)
is_collected = previous_collected.get(product_id, False)
if existing_line:
commands.append(
fields.Command.update(
existing_line.id,
{
"product_uom_id": values["product_uom_id"],
"qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"],
"is_collected": is_collected,
},
)
)
else:
# Ensure all required fields are present before create
if values["product_id"] and values["product_uom_id"]:
commands.append(
fields.Command.create(
{
"product_id": values["product_id"],
"product_uom_id": values["product_uom_id"],
"qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"],
"is_collected": is_collected,
}
)
)
obsolete_lines = [
fields.Command.unlink(line.id)
for line in batch.summary_line_ids
if line.product_id.id not in products_in_totals
]
if commands or obsolete_lines:
batch.summary_line_ids = commands + obsolete_lines
else:
batch.summary_line_ids = [fields.Command.clear()]
def _raise_collected_restriction(self, base_message, product_names):
message = self.env._(base_message)
if product_names:
message += "\n" + self.env._(
"Pending products: %(products)s", products=product_names
)
raise UserError(message)
def _get_not_collected_summary_lines(self, batch_pickings, scope):
summary_lines = self.summary_line_ids
if scope == "processed":
processed_product_ids = set(
batch_pickings.move_line_ids.filtered(
lambda line: (
line.move_id.state not in ("cancel", "done")
and line.product_id
and line.move_id.quantity > 0
)
).mapped("product_id.id")
)
summary_lines = summary_lines.filtered(
lambda line: line.product_id.id in processed_product_ids
)
return summary_lines.filtered(lambda line: not line.is_collected)
def _get_not_collected_detailed_lines(self, batch_pickings, scope):
detailed_lines = batch_pickings.move_line_ids.filtered(
lambda line: line.move_id.state not in ("cancel", "done")
and line.product_id
)
if scope == "processed":
detailed_lines = detailed_lines.filtered(
lambda line: line.move_id.quantity > 0
)
return detailed_lines.filtered(lambda line: not line.is_collected)
def _check_all_products_collected(self, pickings=None):
"""Validate collected restrictions based on tab configuration."""
for batch in self:
company = batch.company_id or self.env.company
batch_pickings = (
pickings.filtered(lambda p, batch=batch: p.batch_id == batch)
if pickings
else batch.picking_ids
)
if not batch_pickings:
continue
if company.batch_detailed_restriction_enabled:
not_collected_detailed = batch._get_not_collected_detailed_lines(
batch_pickings, company.batch_detailed_restriction_scope
)
if not_collected_detailed:
product_names = ", ".join(
sorted(
set(
not_collected_detailed.mapped("product_id.display_name")
)
)
)
batch._raise_collected_restriction(
"You must mark detailed operation lines as collected before validating the batch.",
product_names,
)
if company.batch_summary_restriction_enabled:
not_collected_summary = batch._get_not_collected_summary_lines(
batch_pickings, company.batch_summary_restriction_scope
)
if not_collected_summary:
product_names = ", ".join(
sorted(
set(not_collected_summary.mapped("product_id.display_name"))
)
)
batch._raise_collected_restriction(
"You must mark product summary lines as collected before validating the batch.",
product_names,
)
def action_open_operator_view(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": self.name,
"res_model": "stock.move.line",
"view_mode": "list",
"views": [
(
self.env.ref(
"stock_picking_batch_collect.view_move_line_batch_operator"
).id,
"list",
)
],
"domain": [("batch_id", "=", self.id)],
"context": {
"create": False,
"delete": True,
},
}
class StockPickingBatchSummaryLine(models.Model):
_name = "stock.picking.batch.summary.line"
_description = "Batch Product Summary Line"
_order = "product_categ_id, product_id"
_sql_constraints = [
(
"product_required",
"CHECK(product_id IS NOT NULL)",
"Product is required for summary lines.",
),
]
batch_id = fields.Many2one(
comodel_name="stock.picking.batch",
ondelete="cascade",
required=True,
index=True,
)
product_id = fields.Many2one(
comodel_name="product.product",
required=True,
index=True,
)
product_categ_id = fields.Many2one(
comodel_name="product.category",
string="Product Category",
related="product_id.categ_id",
store=True,
readonly=True,
)
product_default_code = fields.Char(
string="Product Code",
related="product_id.default_code",
readonly=True,
)
product_uom_id = fields.Many2one(
comodel_name="uom.uom",
string="Unit of Measure",
required=True,
)
qty_demanded = fields.Float(
string="Demanded Quantity",
digits="Product Unit of Measure",
required=True,
default=0.0,
)
qty_done = fields.Float(
string="Done Quantity",
digits="Product Unit of Measure",
required=True,
default=0.0,
)
qty_pending = fields.Float(
string="Pending Quantity",
digits="Product Unit of Measure",
compute="_compute_qty_pending",
store=True,
)
is_collected = fields.Boolean(string="Collected", default=False)
@api.depends("qty_demanded", "qty_done")
def _compute_qty_pending(self):
for line in self:
line.qty_pending = line.qty_demanded - line.qty_done

View file

@ -0,0 +1,32 @@
Configuración
=============
Restricciones de validación
---------------------------
En **Inventory > Configuration > Settings**, bloque *Operations*, hay dos
ajustes por compañía:
- **Restricciones del resumen de productos**: exige que las líneas de la
pestaña *Product Summary* estén marcadas como recogidas para validar el lote.
Desactivado por defecto (el check es sólo informativo).
- **Restricciones de operaciones detalladas**: exige que las líneas de la
pestaña *Detailed Operations* estén marcadas como recogidas.
**Activado por defecto**: recién instalado el módulo, un lote no se puede
validar hasta marcar las líneas.
Cada restricción tiene un alcance:
- *Sólo procesadas*: se comprueban únicamente las líneas con cantidad hecha
mayor que cero (por defecto).
- *Todas*: se comprueban todas las líneas del lote, procesadas o no.
Columnas
--------
Las columnas están visibles por defecto. Para ajustarlas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar o desactivar *Partner*,
*Product Category*, *Product Code* o *Collected* según necesidad.

View file

@ -0,0 +1,4 @@
Contribuidores
==============
* Criptomart

View file

@ -0,0 +1,12 @@
Créditos
========
Autor
-----
* Criptomart
Financiador
-----------
* Elika Bilbo

View file

@ -0,0 +1,26 @@
Este módulo convierte un lote de picking en una hoja de trabajo para el operario
que monta las cestas: amplía las operaciones detalladas, añade un resumen por
producto y una vista a pantalla completa para el almacén.
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. Las
líneas del lote se ordenan por categoría, producto y partner.
- ``product_default_code`` (Referencia interna) como columna opcional.
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
ha recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
- Botón **Basket Assembly**: una vista de lista a pantalla completa, con
cantidades grandes y controles táctiles, pensada para tablet en el almacén.
- Restricciones de validación configurables por compañía: el lote no se puede
validar hasta que las líneas estén marcadas como recogidas.
Las columnas se añaden con ``optional="show"`` en la vista de líneas del lote
(salvo ``product_default_code``, oculta por defecto), de modo que el usuario
puede desactivarlas desde el selector de columnas sin recargar la vista.
Si además está instalado ``website_sale_aplicoop``, ese módulo añade a estas
mismas vistas las columnas de grupo de consumo (**Consumer Group** y **Home
Delivery**). Este módulo no depende de él: funciona igual sin la parte de
eCommerce.

View file

@ -0,0 +1,8 @@
Instalación
===========
Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_collect

View file

@ -0,0 +1,29 @@
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para ajustar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Con el lote **en progreso**, el botón **Basket Assembly** abre la vista de
operario: la misma lista de líneas a pantalla completa, con la cantidad en
grande y el check *Collected* como interruptor táctil. Está pensada para
trabajar desde una tablet mientras se montan las cestas.
5. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
6. La cantidad que teclea el operario (el peso real de la balanza) queda fijada:
se marca como *Picked* y ni el planificador ni la validación de otros
albaranes vuelven a modificarla. Marcar **Collected** protege igualmente la
cantidad de la línea. Ver ``stock_move_manual_quantity``.
7. Al validar el lote, según la configuración de la compañía, se comprueba que
las líneas estén recogidas y se avisa con la lista de productos pendientes.
La comprobación se ejecuta después de resolver el backorder, de modo que
sólo bloquea sobre las líneas que realmente se van a procesar.

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_picking_batch_summary_line_user,stock_picking_batch_summary_line_user,model_stock_picking_batch_summary_line,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_picking_batch_summary_line_user stock_picking_batch_summary_line_user model_stock_picking_batch_summary_line base.group_user 1 1 1 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -0,0 +1,48 @@
/* Widen the quantity column in detailed operations so it is easier to tap */
/* Note: arch class lands on the root <div> of the list controller, not <table> */
.o_batch_move_line_list th[data-name="quantity"],
.o_batch_move_line_list td[name="quantity"] {
min-width: 140px;
}
.o_batch_move_line_list td[name="quantity"] .o_field_widget input {
min-height: 44px;
font-size: 1.1rem;
}
/* ── Operator view: full-screen, touch-optimised list ── */
.o_batch_operator_list tbody tr.o_data_row td {
padding-top: 10px;
padding-bottom: 10px;
font-size: 1.05rem;
}
.o_batch_operator_list th[data-name="quantity"],
.o_batch_operator_list td[name="quantity"] {
min-width: 130px;
text-align: center;
}
/* Higher specificity to override the general row td font-size rule */
.o_batch_operator_list tbody tr.o_data_row td[name="quantity"] {
font-size: 1.5rem;
font-weight: 700;
}
.o_batch_operator_list td[name="quantity"] .o_field_widget input {
min-height: 56px;
font-size: 1.5rem;
font-weight: 700;
text-align: center;
}
.o_batch_operator_list td[name="is_collected"] {
min-width: 90px;
text-align: center;
}
.o_batch_operator_list td[name="is_collected"] .form-check-input {
width: 2.8em;
height: 1.5em;
cursor: pointer;
}

View file

@ -0,0 +1,2 @@
from . import test_batch_summary # noqa: F401
from . import test_collected_picked # noqa: F401

View file

@ -0,0 +1,464 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from unittest.mock import patch
from odoo.exceptions import UserError
from odoo.tests import TransactionCase
from odoo.tests import tagged
@tagged("-at_install", "post_install")
class TestBatchSummary(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
cls.location_src = cls.env.ref("stock.stock_location_stock")
cls.location_dest = cls.env.ref("stock.stock_location_customers")
cls.picking_type = cls.env.ref("stock.picking_type_out")
cls.product = cls.env["product.product"].create(
{
"name": "Test Product",
"uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id,
}
)
cls.product_2 = cls.env["product.product"].create(
{
"name": "Test Product 2",
"uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id,
}
)
cls.batch = cls.env["stock.picking.batch"].create(
{"name": "Batch Test", "picking_type_id": cls.picking_type.id}
)
cls.picking1 = cls.env["stock.picking"].create(
{
"picking_type_id": cls.picking_type.id,
"location_id": cls.location_src.id,
"location_dest_id": cls.location_dest.id,
"batch_id": cls.batch.id,
}
)
cls.picking2 = cls.env["stock.picking"].create(
{
"picking_type_id": cls.picking_type.id,
"location_id": cls.location_src.id,
"location_dest_id": cls.location_dest.id,
"batch_id": cls.batch.id,
}
)
# Helpers
def _add_move(self, picking, qty_demanded, qty_done, uom):
move = self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": qty_demanded,
"product_uom": uom.id,
"picking_id": picking.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
if qty_done:
self.env["stock.move.line"].create(
{
"move_id": move.id,
"picking_id": picking.id,
"product_id": self.product.id,
"product_uom_id": self.product.uom_id.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"quantity": qty_done,
}
)
return move
def _recompute_batch(self):
self.batch.invalidate_recordset()
self.batch._compute_summary_line_ids()
def _create_batch_with_pickings(self):
batch = self.env["stock.picking.batch"].create(
{"name": "Batch Flow", "picking_type_id": self.picking_type.id}
)
picking = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
move = self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 2.0,
"product_uom": self.uom_unit.id,
"picking_id": picking.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move.line"].create(
{
"move_id": move.id,
"picking_id": picking.id,
"product_id": self.product.id,
"product_uom_id": self.uom_unit.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"quantity": 2.0,
}
)
picking.batch_id = batch.id
return batch
def _create_partial_batch(self, qty_demanded=2.0, qty_done=1.0, extra_move=False):
batch = self.env["stock.picking.batch"].create(
{"name": "Batch Partial", "picking_type_id": self.picking_type.id}
)
batch.picking_type_id.create_backorder = "ask"
picking = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"batch_id": batch.id,
}
)
move = self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": qty_demanded,
"product_uom": self.uom_unit.id,
"picking_id": picking.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move.line"].create(
{
"move_id": move.id,
"picking_id": picking.id,
"product_id": self.product.id,
"product_uom_id": self.uom_unit.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"quantity": qty_done,
}
)
if extra_move:
self.env["stock.move"].create(
{
"name": self.product_2.name,
"product_id": self.product_2.id,
"product_uom_qty": 1.0,
"product_uom": self.uom_unit.id,
"picking_id": picking.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
batch.action_confirm()
batch._compute_summary_line_ids()
return batch
def _set_batch_restriction_config(self, **vals):
company = self.env.company
previous = {key: company[key] for key in vals}
company.write(vals)
self.addCleanup(lambda: company.write(previous))
# Tests
def test_totals_and_pending_with_conversion(self):
"""Totals aggregate per product with UoM conversion and pending."""
# demand 12 units, done 5 units
self._add_move(self.picking1, qty_demanded=12, qty_done=5, uom=self.uom_unit)
# demand 2 dozens (24 units), done 6 units
self._add_move(self.picking2, qty_demanded=2, qty_done=6, uom=self.uom_dozen)
self._recompute_batch()
self.assertEqual(len(self.batch.summary_line_ids), 1)
line = self.batch.summary_line_ids
self.assertEqual(line.product_id, self.product)
self.assertEqual(line.product_uom_id, self.uom_unit)
self.assertAlmostEqual(line.qty_demanded, 36.0)
self.assertAlmostEqual(line.qty_done, 11.0)
self.assertAlmostEqual(line.qty_pending, 25.0)
def test_collected_flag_preserved_on_recompute(self):
"""Collected stays checked after totals change."""
self._add_move(self.picking1, qty_demanded=1, qty_done=1, uom=self.uom_unit)
self._recompute_batch()
line = self.batch.summary_line_ids
line.is_collected = True
# Add more demand/done to trigger an update
self._add_move(self.picking1, qty_demanded=3, qty_done=2, uom=self.uom_unit)
self._recompute_batch()
self.assertTrue(self.batch.summary_line_ids.is_collected)
def test_cancelled_moves_are_ignored(self):
"""Cancelled moves do not count in the summary and lines are removed."""
move1 = self._add_move(
self.picking1, qty_demanded=4, qty_done=2, uom=self.uom_unit
)
move2 = self._add_move(
self.picking2, qty_demanded=6, qty_done=3, uom=self.uom_unit
)
self._recompute_batch()
self.assertEqual(len(self.batch.summary_line_ids), 1)
move1.write({"state": "cancel"})
move2.write({"state": "cancel"})
self._recompute_batch()
self.assertFalse(self.batch.summary_line_ids)
def test_no_required_product_error_on_confirm(self):
"""Confirming batch with pickings must not create summary lines without product."""
batch = self._create_batch_with_pickings()
# Trigger compute via confirm flow
batch.action_confirm()
self.assertTrue(batch.summary_line_ids)
self.assertFalse(
batch.summary_line_ids.filtered(lambda line: not line.product_id)
)
def test_empty_batch_add_pickings_then_confirm(self):
"""Create empty draft batch, add multiple pickings, then confirm."""
# 1. Create empty batch in draft state
batch = self.env["stock.picking.batch"].create(
{"name": "Batch Empty Start", "picking_type_id": self.picking_type.id}
)
self.assertEqual(batch.state, "draft")
self.assertFalse(batch.picking_ids)
self.assertFalse(batch.summary_line_ids)
# 2. Create pickings with moves (without batch assignment)
picking_a = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 5.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_a.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
picking_b = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move"].create(
{
"name": self.product_2.name,
"product_id": self.product_2.id,
"product_uom_qty": 10.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_b.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
picking_c = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 3.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_c.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
# 3. Add pickings to the batch
picking_a.batch_id = batch
picking_b.batch_id = batch
picking_c.batch_id = batch
self.assertEqual(len(batch.picking_ids), 3)
# 4. Confirm the batch — this should not raise product_id required error
batch.action_confirm()
self.assertEqual(batch.state, "in_progress")
# 5. Verify summary lines are correct
self.assertTrue(batch.summary_line_ids)
self.assertFalse(
batch.summary_line_ids.filtered(lambda line: not line.product_id)
)
# Two products expected
products_in_summary = batch.summary_line_ids.mapped("product_id")
self.assertIn(self.product, products_in_summary)
self.assertIn(self.product_2, products_in_summary)
# Check aggregated quantities
line_product1 = batch.summary_line_ids.filtered(
lambda line: line.product_id == self.product
)
self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
line_product2 = batch.summary_line_ids.filtered(
lambda line: line.product_id == self.product_2
)
self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
def test_done_requires_detailed_lines_collected_without_backorder(self):
"""Full validation blocks when detailed operation lines are unchecked."""
batch = self._create_batch_with_pickings()
batch.action_confirm()
batch._compute_summary_line_ids()
detail_lines = batch.move_line_ids.filtered(lambda line: line.quantity > 0)
self.assertTrue(detail_lines)
self.assertFalse(any(detail_lines.mapped("is_collected")))
with self.assertRaises(UserError):
batch.action_done()
def test_done_ignores_product_summary_checkbox(self):
"""Product Summary checkbox is informative and must not block validation."""
batch = self._create_batch_with_pickings()
batch.action_confirm()
batch._compute_summary_line_ids()
# Keep Product Summary unchecked on purpose
self.assertFalse(any(batch.summary_line_ids.mapped("is_collected")))
# Detailed lines drive validation
batch.move_line_ids.filtered(lambda line: line.quantity > 0).write(
{"is_collected": True}
)
# Should not raise
batch.action_done()
def test_partial_validation_waits_for_backorder_wizard_before_blocking(self):
"""Partial batch validation must open the backorder wizard first."""
batch = self._create_partial_batch()
with patch.object(
type(self.env["stock.picking"]),
"_check_backorder",
autospec=True,
return_value=batch.picking_ids,
):
action = batch.action_done()
self.assertIsInstance(action, dict)
self.assertEqual(action.get("res_model"), "stock.backorder.confirmation")
def test_partial_validation_checks_only_processed_detailed_lines(self):
"""Only processed detailed lines must be required for collected."""
batch = self._create_partial_batch(extra_move=True)
# Ensure product_2 remains non-processed in this scenario
move_product_2 = batch.move_ids.filtered(
lambda move: move.product_id == self.product_2
)
move_product_2.write({"quantity": 0.0})
move_product_2.move_line_ids.write({"quantity": 0.0})
with self.assertRaisesRegex(UserError, self.product.display_name) as err:
batch._check_all_products_collected(batch.picking_ids)
self.assertNotIn(self.product_2.display_name, str(err.exception))
def test_check_all_products_collected_passes_when_all_checked(self):
"""Collected validation helper must pass when all lines are checked."""
batch = self._create_batch_with_pickings()
batch.action_confirm()
batch._compute_summary_line_ids()
batch.move_line_ids.filtered(lambda line: line.quantity > 0).write(
{"is_collected": True}
)
# Should not raise
batch._check_all_products_collected()
def test_summary_restriction_blocks_when_enabled(self):
"""Product Summary restriction can be enabled independently."""
self._set_batch_restriction_config(
batch_summary_restriction_enabled=True,
batch_summary_restriction_scope="processed",
batch_detailed_restriction_enabled=False,
)
batch = self._create_batch_with_pickings()
batch.action_confirm()
batch._compute_summary_line_ids()
# Detailed lines are collected, but Product Summary stays unchecked
batch.move_line_ids.filtered(lambda line: line.quantity > 0).write(
{"is_collected": True}
)
with self.assertRaisesRegex(UserError, "product summary"):
batch.action_done()
def test_detailed_scope_all_blocks_unprocessed_lines(self):
"""Detailed scope 'all' must include non-processed detailed lines."""
self._set_batch_restriction_config(
batch_summary_restriction_enabled=False,
batch_detailed_restriction_enabled=True,
batch_detailed_restriction_scope="all",
)
batch = self._create_partial_batch(extra_move=True)
with self.assertRaisesRegex(UserError, self.product_2.display_name):
batch._check_all_products_collected(batch.picking_ids)

View file

@ -0,0 +1,73 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import Command
from odoo.tests import TransactionCase
from odoo.tests import tagged
@tagged("-at_install", "post_install")
class TestCollectedPicked(TransactionCase):
"""Collecting a line protects its quantity from the reservation engine."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.stock_location = cls.env.ref("stock.stock_location_stock")
cls.customer_location = cls.env.ref("stock.stock_location_customers")
cls.picking_type = cls.env.ref("stock.picking_type_out")
cls.product = cls.env["product.product"].create(
{
"name": "Collected Apples",
"is_storable": True,
"uom_id": cls.env.ref("uom.product_uom_kgm").id,
"uom_po_id": cls.env.ref("uom.product_uom_kgm").id,
}
)
cls.env["stock.quant"]._update_available_quantity(
cls.product, cls.stock_location, 50.0
)
def _create_picking(self, demand):
picking = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
"move_ids": [
Command.create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"product_uom_qty": demand,
"location_id": self.stock_location.id,
"location_dest_id": self.customer_location.id,
}
)
],
}
)
picking.action_confirm()
picking.action_assign()
return picking
def test_collecting_a_line_marks_it_picked(self):
picking = self._create_picking(2.0)
line = picking.move_ids.move_line_ids
line.is_collected = True
self.assertTrue(line.picked)
self.assertTrue(picking.move_ids.picked)
def test_collecting_a_line_keeps_its_demand(self):
"""Only a hand-typed quantity lowers the demand, collecting does not."""
picking = self._create_picking(2.0)
move = picking.move_ids
move.move_line_ids.write({"quantity": 1.5, "picked": True})
move.move_line_ids.is_collected = True
self.assertEqual(move.product_uom_qty, 1.5)
self.assertTrue(move.picked)

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_batch_custom" model="ir.ui.view">
<field name="name">res.config.settings.view.form.stock.picking.batch.custom</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="stock.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//app[@name='stock']/block[@name='operations_setting_container']" position="inside">
<setting id="batch_product_summary_restrictions" string="Batch Product Summary Restriction">
<field name="batch_summary_restriction_enabled"/>
<div class="content-group" invisible="not batch_summary_restriction_enabled">
<div class="mt8">
<label for="batch_summary_restriction_scope"/>
<field name="batch_summary_restriction_scope"/>
</div>
</div>
<div class="text-muted">
Configuración de restricciones para la pestaña Product Summary.
</div>
</setting>
<setting id="batch_detailed_operations_restrictions" string="Batch Detailed Operations Restriction">
<field name="batch_detailed_restriction_enabled"/>
<div class="content-group" invisible="not batch_detailed_restriction_enabled">
<div class="mt8">
<label for="batch_detailed_restriction_scope"/>
<field name="batch_detailed_restriction_scope"/>
</div>
</div>
<div class="text-muted">
Configuración de restricciones para la pestaña Operaciones Detalladas.
</div>
</setting>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_picking_batch_summary_line_tree" model="ir.ui.view">
<field name="name">stock.picking.batch.summary.line.tree</field>
<field name="model">stock.picking.batch.summary.line</field>
<field name="arch" type="xml">
<list create="0" delete="0" default_order="product_categ_id,product_id">
<field name="product_categ_id" readonly="1"/>
<field name="product_id" readonly="1" context="{'display_default_code': False}"/>
<field name="product_default_code" readonly="1" optional="hide"/>
<field name="product_uom_id" readonly="1" optional="hide"/>
<field name="qty_demanded" readonly="1"/>
<field name="qty_done" readonly="1"/>
<field name="qty_pending" readonly="1"/>
<field name="is_collected" widget="boolean_toggle" nolabel="1"/>
</list>
</field>
</record>
<record id="view_move_line_batch_operator" model="ir.ui.view">
<field name="name">stock.move.line.batch.operator</field>
<field name="model">stock.move.line</field>
<field name="arch" type="xml">
<list create="0" delete="1" editable="bottom"
default_order="product_categ_id,product_id,picking_partner_id"
class="o_batch_operator_list">
<field name="batch_id" column_invisible="True"/>
<field name="product_categ_id" readonly="1" optional="show"/>
<field name="product_id" readonly="1"
context="{'display_default_code': False}"/>
<field name="reference" readonly="1" optional="hide"/>
<field name="picking_partner_id" readonly="1" optional="show"/>
<field name="quantity" string="Cant."/>
<!-- Loaded so `_onchange_quantity_manual` reaches the server. -->
<field name="picked" column_invisible="True"/>
<field name="is_collected" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_stock_picking_batch_form_inherit_summary" model="ir.ui.view">
<field name="name">stock.picking.batch.form.summary</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_done']" position="before">
<button name="action_open_operator_view"
string="Basket Assembly"
type="object"
class="btn-secondary"
invisible="state != 'in_progress'"/>
</xpath>
<xpath expr="//notebook/page[@name='page_detailed_operations']" position="after">
<page string="Product Summary" name="page_product_summary">
<field name="summary_line_ids" context="{'tree_view_ref': 'stock_picking_batch_collect.view_stock_picking_batch_summary_line_tree'}"/>
</page>
</xpath>
</field>
</record>
<record id="view_move_line_tree_inherit_batch_custom" model="ir.ui.view">
<field name="name">stock.move.line.list.batch.custom</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="default_order">product_categ_id,product_id,picking_partner_id</attribute>
<attribute name="class">o_batch_move_line_list</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="before">
<field name="product_categ_id" readonly="1"/>
</xpath>
<xpath expr="//field[@name='product_id']" position="attributes">
<attribute name="context">{'display_default_code': False}</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="picking_partner_id" readonly="1" optional="show"/>
<field name="product_default_code" readonly="1" optional="hide"/>
<field name="is_collected" widget="boolean_toggle" optional="show"/>
</xpath>
<xpath expr="//field[@name='quantity']" position="after">
<!-- Loaded so `_onchange_quantity_manual` reaches the server. -->
<field name="picked" column_invisible="True"/>
</xpath>
<xpath expr="//field[@name='product_uom_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='company_id'][@groups='base.group_multi_company']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='picking_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='location_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='location_dest_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
</field>
</record>
</odoo>