Merge pull request '[IMP] stock_picking_batch_collect: checks en albaranes, cantidad pedida e informe' (#18) from 18.0-imp-stock_picking_batch_collect into 18.0

Reviewed-on: #18
This commit is contained in:
snt 2026-10-04 17:41:51 +00:00
commit 26efdf1665
24 changed files with 630 additions and 39 deletions

View file

@ -235,7 +235,7 @@ addons-cm/
**Logística y contabilidad** **Logística y contabilidad**
- [stock_picking_batch_collect](../stock_picking_batch_collect/README.rst) - Columnas extra, resumen por producto y vista operario para batch picking - [stock_picking_batch_collect](../stock_picking_batch_collect/README.rst) - Columnas extra, resumen por producto y vista operario para batch picking; checks de recogido también en albaranes sueltos, cantidad pedida en venta e informe con todos los albaranes del lote
- [web_list_striped](../web_list_striped/README.rst) - Efecto cebra en todas las listas del backend (sólo CSS) - [web_list_striped](../web_list_striped/README.rst) - Efecto cebra en todas las listas del backend (sólo CSS)
- [account_banking_mandate_batch](../account_banking_mandate_batch/README.rst) - Creación masiva de mandatos SEPA desde contactos - [account_banking_mandate_batch](../account_banking_mandate_batch/README.rst) - Creación masiva de mandatos SEPA desde contactos
- [l10n_es_edi_tbai_reagyp_recibidas](../l10n_es_edi_tbai_reagyp_recibidas/README.rst) - Fix TicketBAI REAGYP en facturas recibidas (clave régimen 19 → 02) - [l10n_es_edi_tbai_reagyp_recibidas](../l10n_es_edi_tbai_reagyp_recibidas/README.rst) - Fix TicketBAI REAGYP en facturas recibidas (clave régimen 19 → 02)
@ -761,6 +761,9 @@ last_purchase_price_compute_type != "manual_update" # Para auto-cálculo
`product_pricelist_total_margin`, `product_origin_char`, `l10n_es_edi_tbai_reagyp_recibidas`. `product_pricelist_total_margin`, `product_origin_char`, `l10n_es_edi_tbai_reagyp_recibidas`.
`product_origin` (OCA) sustituido por `product_origin_char` (custom). `product_origin` (OCA) sustituido por `product_origin_char` (custom).
- **2026-06-04**: `product_sale_price_from_pricelist` aplica descuentos de proveedor desde `supplierinfo`. - **2026-06-04**: `product_sale_price_from_pricelist` aplica descuentos de proveedor desde `supplierinfo`.
- **2026-10-04**: `stock_picking_batch_collect` (v18.0.2.1.0) — checks de recogido en albaranes
sueltos (activables por tipo de operación), columna de cantidad pedida en la venta (depende de
`sale_stock`) e informe *Albaranes* del lote.
- **2026-08-27**: `stock_picking_batch_custom` renombrado a `stock_picking_batch_collect` - **2026-08-27**: `stock_picking_batch_custom` renombrado a `stock_picking_batch_collect`
(v18.0.2.0.0). Se invierte la dependencia: ya no depende de `website_sale_aplicoop`; es este (v18.0.2.0.0). Se invierte la dependencia: ya no depende de `website_sale_aplicoop`; es este
el que depende de él e inyecta las columnas de grupo de consumo. El efecto cebra global se el que depende de él e inyecta las columnas de grupo de consumo. El efecto cebra global se

View file

@ -21,6 +21,15 @@ producto y una vista a pantalla completa para el almacén.
cantidades grandes y controles táctiles, pensada para tablet en el almacén. 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 - Restricciones de validación configurables por compañía: el lote no se puede
validar hasta que las líneas estén marcadas como recogidas. validar hasta que las líneas estén marcadas como recogidas.
- Los mismos checks en los albaranes sueltos (fuera de un lote), activables por
tipo de operación.
- Columna **Cantidad pedida** (``sale_ordered_qty``), de sólo lectura, con lo
que pidió el cliente en el pedido de venta. Al teclear el peso real la demanda
del movimiento se ajusta a lo pesado, así que esta columna es la que permite
comparar lo pedido con lo preparado en la misma pantalla. Está en las
operaciones detalladas, la vista de operario, la pestaña *Operations* y el
resumen por producto del lote, y en el albarán.
- Informe **Albaranes** en el lote: todos sus albaranes de entrega en un PDF.
Las columnas se añaden con ``optional="show"`` en la vista de líneas del lote 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 (salvo ``product_default_code``, oculta por defecto), de modo que el usuario
@ -68,6 +77,17 @@ Cada restricción tiene un alcance:
mayor que cero (por defecto). mayor que cero (por defecto).
- *Todas*: se comprueban todas las líneas del lote, procesadas o no. - *Todas*: se comprueban todas las líneas del lote, procesadas o no.
Albaranes fuera de un lote
--------------------------
Para exigir los checks también en los albaranes sueltos, marcar **Exigir líneas
recogidas** en el tipo de operación (**Inventory > Configuration > Operation
Types**, p. ej. *Delivery Orders*). Viene desactivado: sin él, los albaranes
sueltos de ese tipo se validan como siempre.
Un albarán suelto no tiene resumen por producto, así que las dos restricciones
comprueban sus líneas; si alguna usa el alcance *Todas*, se aplica ese.
Columnas Columnas
-------- --------
@ -108,6 +128,19 @@ Uso
La comprobación se ejecuta después de resolver el backorder, de modo que 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. sólo bloquea sobre las líneas que realmente se van a procesar.
8. En un albarán suelto, la pestaña **Operations** tiene un check
**Collected** por producto, que marca todas sus líneas; las operaciones
detalladas lo tienen por línea. Si su tipo de operación lo exige, el
albarán no se valida hasta marcarlos.
9. La columna **Cantidad pedida** muestra lo que pidió el cliente, frente a la
demanda (ya ajustada al peso) y la cantidad preparada. En el resumen por
producto del lote es la suma de los pedidos.
10. **Imprimir > Albaranes** en el lote genera un único PDF con el albarán de
entrega de cada uno de sus traslados (salvo los cancelados), ordenados por
cliente.
Contribuidores Contribuidores
============== ==============

View file

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

View file

@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018 { # noqa: B018
"name": "Stock Picking Batch Collect", "name": "Stock Picking Batch Collect",
"version": "18.0.2.0.0", "version": "18.0.2.1.0",
"category": "Warehouse", "category": "Warehouse",
"summary": "Collect batch operations: operator view, extra columns and product summary", "summary": "Collect batch operations: operator view, extra columns and product summary",
"author": "Odoo Community Association (OCA), Criptomart", "author": "Odoo Community Association (OCA), Criptomart",
@ -10,6 +10,9 @@
"website": "https://github.com/Criptomart", "website": "https://github.com/Criptomart",
"license": "AGPL-3", "license": "AGPL-3",
"depends": [ "depends": [
# The ordered quantity of the sale line, which the weighed demand
# overwrites.
"sale_stock",
# A quantity typed by the operator is the weighed one: it must not be # A quantity typed by the operator is the weighed one: it must not be
# re-reserved by the scheduler. # re-reserved by the scheduler.
"stock_move_manual_quantity", "stock_move_manual_quantity",
@ -19,6 +22,8 @@
"security/ir.model.access.csv", "security/ir.model.access.csv",
"views/res_config_settings_views.xml", "views/res_config_settings_views.xml",
"views/stock_picking_batch_views.xml", "views/stock_picking_batch_views.xml",
"views/stock_picking_views.xml",
"report/report_batch_deliveryslip.xml",
], ],
"assets": { "assets": {
"web.assets_backend": [ "web.assets_backend": [

View file

@ -62,6 +62,7 @@ msgid "Batch Transfer"
msgstr "Traslado por lote" msgstr "Traslado por lote"
#. module: stock_picking_batch_collect #. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move__is_collected
#: 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_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected #: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected
msgid "Collected" msgid "Collected"
@ -233,3 +234,51 @@ msgstr "Si este albarán incluye entrega a domicilio (del pedido de venta)"
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary #: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly" msgid "Basket Assembly"
msgstr "Montaje de Cestas" msgstr "Montaje de Cestas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move__sale_ordered_qty
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__sale_ordered_qty
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_ordered
msgid "Ordered Quantity"
msgstr "Cantidad pedida"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_move_line_batch_operator
msgid "Ordered"
msgstr "Pedido"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_type__collected_check_required
msgid "Require Collected Lines"
msgstr "Exigir líneas recogidas"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,help:stock_picking_batch_collect.field_stock_picking_type__collected_check_required
msgid ""
"Transfers of this type that are not in a batch can only be validated once "
"their lines are marked as collected, following the batch validation "
"restrictions of the inventory settings."
msgstr ""
"Los albaranes de este tipo que no estén en un lote solo se pueden validar "
"cuando sus líneas estén marcadas como recogidas, según las restricciones de "
"validación de lotes de los ajustes de inventario."
#. module: stock_picking_batch_collect
#: model:ir.actions.report,name:stock_picking_batch_collect.action_report_batch_deliveryslip
msgid "Delivery Slips"
msgstr "Albaranes"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_report_stock_picking_batch_collect_report_batch_deliveryslip
msgid "Batch Delivery Slips"
msgstr "Albaranes del lote"
#. module: stock_picking_batch_collect
#: code:addons/stock_picking_batch_collect/models/stock_picking.py:0
#, python-format
msgid ""
"You must mark the operations of %(picking)s as collected before validating it.\n"
"Pending products: %(products)s"
msgstr ""
"Hay que marcar como recogidas las operaciones de %(picking)s antes de validarlo.\n"
"Productos pendientes: %(products)s"

View file

@ -62,6 +62,7 @@ msgid "Batch Transfer"
msgstr "Lote-transferentzia" msgstr "Lote-transferentzia"
#. module: stock_picking_batch_collect #. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move__is_collected
#: 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_move_line__is_collected
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected #: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__is_collected
msgid "Collected" msgid "Collected"
@ -233,3 +234,51 @@ msgstr ""
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary #: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_stock_picking_batch_form_inherit_summary
msgid "Basket Assembly" msgid "Basket Assembly"
msgstr "Saskien Prestaketa" msgstr "Saskien Prestaketa"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move__sale_ordered_qty
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_move_line__sale_ordered_qty
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_batch_summary_line__qty_ordered
msgid "Ordered Quantity"
msgstr "Eskaeraren kantitatea"
#. module: stock_picking_batch_collect
#: model_terms:ir.ui.view,arch_db:stock_picking_batch_collect.view_move_line_batch_operator
msgid "Ordered"
msgstr "Eskatua"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,field_description:stock_picking_batch_collect.field_stock_picking_type__collected_check_required
msgid "Require Collected Lines"
msgstr "Jasotako lerroak eskatu"
#. module: stock_picking_batch_collect
#: model:ir.model.fields,help:stock_picking_batch_collect.field_stock_picking_type__collected_check_required
msgid ""
"Transfers of this type that are not in a batch can only be validated once "
"their lines are marked as collected, following the batch validation "
"restrictions of the inventory settings."
msgstr ""
"Lote batean ez dauden mota honetako transferentziak beren lerroak jasota "
"gisa markatuta daudenean bakarrik balida daitezke, inbentarioaren "
"ezarpenetako loteen balidazio-murrizketen arabera."
#. module: stock_picking_batch_collect
#: model:ir.actions.report,name:stock_picking_batch_collect.action_report_batch_deliveryslip
msgid "Delivery Slips"
msgstr "Entrega-albaranak"
#. module: stock_picking_batch_collect
#: model:ir.model,name:stock_picking_batch_collect.model_report_stock_picking_batch_collect_report_batch_deliveryslip
msgid "Batch Delivery Slips"
msgstr "Loteko entrega-albaranak"
#. module: stock_picking_batch_collect
#: code:addons/stock_picking_batch_collect/models/stock_picking.py:0
#, python-format
msgid ""
"You must mark the operations of %(picking)s as collected before validating it.\n"
"Pending products: %(products)s"
msgstr ""
"%(picking)s balidatu aurretik, bere eragiketak jasota gisa markatu behar dira.\n"
"Zain dauden produktuak: %(products)s"

View file

@ -0,0 +1,15 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import SUPERUSER_ID
from odoo import api
def migrate(cr, version):
"""Fill the new ordered quantity of the open batches' product summary."""
env = api.Environment(cr, SUPERUSER_ID, {})
batches = env["stock.picking.batch"].search(
[("state", "in", ("draft", "in_progress"))]
)
env.add_to_compute(env["stock.picking.batch"]._fields["summary_line_ids"], batches)
batches.flush_recordset(["summary_line_ids"])

View file

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

View file

@ -7,17 +7,15 @@ from odoo import models
class StockBackorderConfirmation(models.TransientModel): class StockBackorderConfirmation(models.TransientModel):
_inherit = "stock.backorder.confirmation" _inherit = "stock.backorder.confirmation"
def _check_batch_summary_lines(self): def _check_collected_products(self):
pickings = self.env["stock.picking"].browse( self.env["stock.picking"].browse(
self.env.context.get("button_validate_picking_ids", []) self.env.context.get("button_validate_picking_ids", [])
) )._check_collected_products()
for batch in pickings.batch_id:
batch._check_all_products_collected(pickings)
def process(self): def process(self):
self._check_batch_summary_lines() self._check_collected_products()
return super().process() return super().process()
def process_cancel_backorder(self): def process_cancel_backorder(self):
self._check_batch_summary_lines() self._check_collected_products()
return super().process_cancel_backorder() return super().process_cancel_backorder()

View file

@ -0,0 +1,55 @@
# 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
class StockMove(models.Model):
_inherit = "stock.move"
is_collected = fields.Boolean(
string="Collected",
compute="_compute_is_collected",
inverse="_inverse_is_collected",
)
sale_ordered_qty = fields.Float(
string="Ordered Quantity",
digits="Product Unit of Measure",
compute="_compute_sale_ordered_qty",
)
@api.depends("move_line_ids.is_collected")
def _compute_is_collected(self):
for move in self:
lines = move.move_line_ids
move.is_collected = bool(lines) and all(lines.mapped("is_collected"))
def _inverse_is_collected(self):
collected = self.filtered("is_collected")
collected.move_line_ids.filtered(
lambda line: not line.is_collected
).is_collected = True
(self - collected).move_line_ids.filtered("is_collected").is_collected = False
@api.depends(
"product_id",
"product_uom",
"sale_line_id.product_id",
"sale_line_id.product_uom",
"sale_line_id.product_uom_qty",
)
def _compute_sale_ordered_qty(self):
"""What the customer ordered, which the weighed demand overwrites.
Kit components carry the kit's sale line, whose quantity is not theirs.
"""
for move in self:
line = move.sale_line_id
if line and line.product_id == move.product_id and move.product_uom:
move.sale_ordered_qty = line.product_uom._compute_quantity(
line.product_uom_qty, move.product_uom, round=False
)
else:
move.sale_ordered_qty = 0.0

View file

@ -1,6 +1,7 @@
# Copyright 2026 Criptomart # Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import fields from odoo import fields
from odoo import models from odoo import models
@ -35,6 +36,23 @@ class StockMoveLine(models.Model):
copy=False, copy=False,
) )
sale_ordered_qty = fields.Float(
string="Ordered Quantity",
digits="Product Unit of Measure",
compute="_compute_sale_ordered_qty",
)
@api.depends("move_id.sale_ordered_qty", "move_id.product_uom", "product_uom_id")
def _compute_sale_ordered_qty(self):
for line in self:
move = line.move_id
if move.sale_ordered_qty and line.product_uom_id:
line.sale_ordered_qty = move.product_uom._compute_quantity(
move.sale_ordered_qty, line.product_uom_id, round=False
)
else:
line.sale_ordered_qty = 0.0
def write(self, vals): def write(self, vals):
res = super().write(vals) res = super().write(vals)
if vals.get("is_collected"): if vals.get("is_collected"):

View file

@ -2,6 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models from odoo import models
from odoo.exceptions import UserError
class StockPicking(models.Model): class StockPicking(models.Model):
@ -19,21 +20,68 @@ class StockPicking(models.Model):
if res is not True: if res is not True:
return res return res
self._check_batch_collected_products() self._check_collected_products()
return res return res
def _check_batch_collected_products(self): def _get_open_move_lines(self, scope):
"""Move lines still to process; ``processed`` keeps those with quantity."""
lines = self.move_line_ids.filtered(
lambda line: line.move_id.state not in ("cancel", "done")
and line.product_id
)
if scope == "processed":
lines = lines.filtered(lambda line: line.move_id.quantity > 0)
return lines
def _get_not_collected_move_lines(self, scope):
return self._get_open_move_lines(scope).filtered(
lambda line: not line.is_collected
)
def _check_collected_products(self):
for batch in self.batch_id: for batch in self.batch_id:
batch_pickings = self.filtered( batch_pickings = self.filtered(
lambda picking, batch=batch: picking.batch_id == batch lambda picking, batch=batch: picking.batch_id == batch
) )
processed_move_lines = batch_pickings.move_line_ids.filtered( if batch_pickings._get_open_move_lines("processed"):
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) batch._check_all_products_collected(batch_pickings)
loose_pickings = self.filtered(
lambda picking: not picking.batch_id
and picking.picking_type_id.collected_check_required
)
for picking in loose_pickings:
if picking._get_open_move_lines("processed"):
picking._check_picking_collected()
def _check_picking_collected(self):
"""Apply the batch restrictions to a transfer outside a batch.
A single transfer has no product summary: the toggle of its Operations
tab collects every line of the product, so both restrictions check its
move lines, with the widest scope enabled.
"""
self.ensure_one()
company = self.company_id
scopes = set()
if company.batch_detailed_restriction_enabled:
scopes.add(company.batch_detailed_restriction_scope)
if company.batch_summary_restriction_enabled:
scopes.add(company.batch_summary_restriction_scope)
if not scopes:
return
scope = "all" if "all" in scopes else "processed"
not_collected = self._get_not_collected_move_lines(scope)
if not not_collected:
return
product_names = ", ".join(
sorted(set(not_collected.mapped("product_id.display_name")))
)
raise UserError(
self.env._(
"You must mark the operations of %(picking)s as collected "
"before validating it.\nPending products: %(products)s",
picking=self.name,
products=product_names,
)
)

View file

@ -25,10 +25,12 @@ class StockPickingBatch(models.Model):
"move_line_ids.move_id.quantity", "move_line_ids.move_id.quantity",
"move_line_ids.move_id.product_uom", "move_line_ids.move_id.product_uom",
"move_line_ids.move_id.state", "move_line_ids.move_id.state",
"move_line_ids.move_id.sale_line_id.product_uom_qty",
) )
def _compute_summary_line_ids(self): def _compute_summary_line_ids(self):
"""Aggregate move quantities per product and keep collected flag. """Aggregate move quantities per product and keep collected flag.
- Ordered: sale line quantity converted to product UoM, once per line.
- Demand: move.product_uom_qty converted to product UoM. - Demand: move.product_uom_qty converted to product UoM.
- Done: move.quantity converted to product UoM. - Done: move.quantity converted to product UoM.
- Pending: demand - done. - Pending: demand - done.
@ -49,6 +51,7 @@ class StockPickingBatch(models.Model):
} }
# Use regular dict to avoid accidental creation of empty entries # Use regular dict to avoid accidental creation of empty entries
aggregates = {} aggregates = {}
counted_sale_lines = set()
# Demand per move (count once per move) # Demand per move (count once per move)
for move in batch.move_ids: for move in batch.move_ids:
@ -63,10 +66,22 @@ class StockPickingBatch(models.Model):
aggregates[product.id] = { aggregates[product.id] = {
"product_id": product.id, "product_id": product.id,
"product_uom_id": product_uom.id, "product_uom_id": product_uom.id,
"qty_ordered": 0.0,
"qty_demanded": 0.0, "qty_demanded": 0.0,
"qty_done": 0.0, "qty_done": 0.0,
} }
aggregates[product.id]["qty_demanded"] += demand aggregates[product.id]["qty_demanded"] += demand
if move.sale_ordered_qty and move.sale_line_id.id not in (
counted_sale_lines
):
counted_sale_lines.add(move.sale_line_id.id)
aggregates[product.id][
"qty_ordered"
] += move.product_uom._compute_quantity(
move.sale_ordered_qty,
product_uom,
rounding_method="HALF-UP",
)
# Done per move line # Done per move line
for line in batch.move_line_ids: for line in batch.move_line_ids:
@ -84,6 +99,7 @@ class StockPickingBatch(models.Model):
aggregates[product.id] = { aggregates[product.id] = {
"product_id": product.id, "product_id": product.id,
"product_uom_id": product_uom.id, "product_uom_id": product_uom.id,
"qty_ordered": 0.0,
"qty_demanded": 0.0, "qty_demanded": 0.0,
"qty_done": 0.0, "qty_done": 0.0,
} }
@ -108,6 +124,7 @@ class StockPickingBatch(models.Model):
existing_line.id, existing_line.id,
{ {
"product_uom_id": values["product_uom_id"], "product_uom_id": values["product_uom_id"],
"qty_ordered": values["qty_ordered"],
"qty_demanded": values["qty_demanded"], "qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"], "qty_done": values["qty_done"],
"is_collected": is_collected, "is_collected": is_collected,
@ -122,6 +139,7 @@ class StockPickingBatch(models.Model):
{ {
"product_id": values["product_id"], "product_id": values["product_id"],
"product_uom_id": values["product_uom_id"], "product_uom_id": values["product_uom_id"],
"qty_ordered": values["qty_ordered"],
"qty_demanded": values["qty_demanded"], "qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"], "qty_done": values["qty_done"],
"is_collected": is_collected, "is_collected": is_collected,
@ -151,30 +169,16 @@ class StockPickingBatch(models.Model):
def _get_not_collected_summary_lines(self, batch_pickings, scope): def _get_not_collected_summary_lines(self, batch_pickings, scope):
summary_lines = self.summary_line_ids summary_lines = self.summary_line_ids
if scope == "processed": if scope == "processed":
processed_product_ids = set( processed_products = batch_pickings._get_open_move_lines(
batch_pickings.move_line_ids.filtered( "processed"
lambda line: ( ).product_id
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( summary_lines = summary_lines.filtered(
lambda line: line.product_id.id in processed_product_ids lambda line: line.product_id in processed_products
) )
return summary_lines.filtered(lambda line: not line.is_collected) return summary_lines.filtered(lambda line: not line.is_collected)
def _get_not_collected_detailed_lines(self, batch_pickings, scope): def _get_not_collected_detailed_lines(self, batch_pickings, scope):
detailed_lines = batch_pickings.move_line_ids.filtered( return batch_pickings._get_not_collected_move_lines(scope)
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): def _check_all_products_collected(self, pickings=None):
"""Validate collected restrictions based on tab configuration.""" """Validate collected restrictions based on tab configuration."""
@ -285,6 +289,11 @@ class StockPickingBatchSummaryLine(models.Model):
string="Unit of Measure", string="Unit of Measure",
required=True, required=True,
) )
qty_ordered = fields.Float(
string="Ordered Quantity",
digits="Product Unit of Measure",
default=0.0,
)
qty_demanded = fields.Float( qty_demanded = fields.Float(
string="Demanded Quantity", string="Demanded Quantity",
digits="Product Unit of Measure", digits="Product Unit of Measure",

View file

@ -0,0 +1,16 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class StockPickingType(models.Model):
_inherit = "stock.picking.type"
collected_check_required = fields.Boolean(
string="Require Collected Lines",
help="Transfers of this type that are not in a batch can only be "
"validated once their lines are marked as collected, following the "
"batch validation restrictions of the inventory settings.",
)

View file

@ -21,6 +21,17 @@ Cada restricción tiene un alcance:
mayor que cero (por defecto). mayor que cero (por defecto).
- *Todas*: se comprueban todas las líneas del lote, procesadas o no. - *Todas*: se comprueban todas las líneas del lote, procesadas o no.
Albaranes fuera de un lote
--------------------------
Para exigir los checks también en los albaranes sueltos, marcar **Exigir líneas
recogidas** en el tipo de operación (**Inventory > Configuration > Operation
Types**, p. ej. *Delivery Orders*). Viene desactivado: sin él, los albaranes
sueltos de ese tipo se validan como siempre.
Un albarán suelto no tiene resumen por producto, así que las dos restricciones
comprueban sus líneas; si alguna usa el alcance *Todas*, se aplica ese.
Columnas Columnas
-------- --------

View file

@ -15,6 +15,15 @@ producto y una vista a pantalla completa para el almacén.
cantidades grandes y controles táctiles, pensada para tablet en el almacén. 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 - Restricciones de validación configurables por compañía: el lote no se puede
validar hasta que las líneas estén marcadas como recogidas. validar hasta que las líneas estén marcadas como recogidas.
- Los mismos checks en los albaranes sueltos (fuera de un lote), activables por
tipo de operación.
- Columna **Cantidad pedida** (``sale_ordered_qty``), de sólo lectura, con lo
que pidió el cliente en el pedido de venta. Al teclear el peso real la demanda
del movimiento se ajusta a lo pesado, así que esta columna es la que permite
comparar lo pedido con lo preparado en la misma pantalla. Está en las
operaciones detalladas, la vista de operario, la pestaña *Operations* y el
resumen por producto del lote, y en el albarán.
- Informe **Albaranes** en el lote: todos sus albaranes de entrega en un PDF.
Las columnas se añaden con ``optional="show"`` en la vista de líneas del lote 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 (salvo ``product_default_code``, oculta por defecto), de modo que el usuario

View file

@ -27,3 +27,16 @@ Uso
las líneas estén recogidas y se avisa con la lista de productos pendientes. 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 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. sólo bloquea sobre las líneas que realmente se van a procesar.
8. En un albarán suelto, la pestaña **Operations** tiene un check
**Collected** por producto, que marca todas sus líneas; las operaciones
detalladas lo tienen por línea. Si su tipo de operación lo exige, el
albarán no se valida hasta marcarlos.
9. La columna **Cantidad pedida** muestra lo que pidió el cliente, frente a la
demanda (ya ajustada al peso) y la cantidad preparada. En el resumen por
producto del lote es la suma de los pedidos.
10. **Imprimir > Albaranes** en el lote genera un único PDF con el albarán de
entrega de cada uno de sus traslados (salvo los cancelados), ordenados por
cliente.

View file

@ -0,0 +1 @@
from . import report_batch_deliveryslip # noqa: F401

View file

@ -0,0 +1,25 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import models
class ReportBatchDeliverySlip(models.AbstractModel):
_name = "report.stock_picking_batch_collect.report_batch_deliveryslip"
_description = "Batch Delivery Slips"
@api.model
def _get_report_values(self, docids, data=None):
batches = self.env["stock.picking.batch"].browse(docids)
pickings = self.env["stock.picking"]
for batch in batches:
pickings |= batch.picking_ids.filtered(
lambda picking: picking.state != "cancel"
).sorted(lambda picking: (picking.partner_id.name or "", picking.name))
return {
"doc_ids": docids,
"doc_model": "stock.picking.batch",
"docs": batches,
"pickings": pickings,
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_batch_deliveryslip" model="ir.actions.report">
<field name="name">Delivery Slips</field>
<field name="model">stock.picking.batch</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">stock_picking_batch_collect.report_batch_deliveryslip</field>
<field name="report_file">stock_picking_batch_collect.report_batch_deliveryslip</field>
<field name="print_report_name">'Delivery Slips - %s' % object.name</field>
<field name="binding_model_id" ref="stock_picking_batch.model_stock_picking_batch"/>
<field name="binding_type">report</field>
</record>
<template id="report_batch_deliveryslip">
<t t-foreach="pickings" t-as="o">
<t t-call="stock.report_delivery_document" t-lang="o._get_report_lang()"/>
</t>
</template>
</odoo>

View file

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

View file

@ -0,0 +1,137 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import TransactionCase
from odoo.tests import tagged
@tagged("-at_install", "post_install")
class TestPickingCollected(TransactionCase):
"""Collected checks and ordered quantities on transfers outside a batch."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.stock_location = cls.env.ref("stock.stock_location_stock")
cls.picking_type_out = cls.env.ref("stock.picking_type_out")
cls.picking_type_out.collected_check_required = True
cls.env.company.write(
{
"batch_detailed_restriction_enabled": True,
"batch_detailed_restriction_scope": "processed",
"batch_summary_restriction_enabled": False,
}
)
kg = cls.env.ref("uom.product_uom_kgm")
cls.product = cls.env["product.product"].create(
{
"name": "Collected Pears",
"is_storable": True,
"uom_id": kg.id,
"uom_po_id": kg.id,
}
)
cls.env["stock.quant"]._update_available_quantity(
cls.product, cls.stock_location, 50.0
)
cls.partner = cls.env["res.partner"].create({"name": "Pear Lover"})
def _create_sale_picking(self, qty=2.0):
order = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"order_line": [
Command.create(
{"product_id": self.product.id, "product_uom_qty": qty}
)
],
}
)
order.action_confirm()
picking = order.picking_ids
picking.action_assign()
return picking
def test_loose_picking_blocks_until_collected(self):
picking = self._create_sale_picking()
with self.assertRaisesRegex(UserError, self.product.display_name):
picking.button_validate()
picking.move_ids.is_collected = True
picking.button_validate()
self.assertEqual(picking.state, "done")
def test_picking_type_without_check_is_not_blocked(self):
self.picking_type_out.collected_check_required = False
picking = self._create_sale_picking()
picking.button_validate()
self.assertEqual(picking.state, "done")
def test_batch_picking_follows_the_batch_restriction(self):
"""The batch summary flag is not required by the loose transfer check."""
self.env.company.batch_summary_restriction_enabled = True
picking = self._create_sale_picking()
batch = self.env["stock.picking.batch"].create(
{"picking_ids": [Command.set(picking.ids)]}
)
batch.action_confirm()
picking.move_ids.is_collected = True
with self.assertRaisesRegex(UserError, "product summary"):
batch.action_done()
def test_collecting_the_move_collects_its_lines(self):
picking = self._create_sale_picking()
move = picking.move_ids
self.assertFalse(move.is_collected)
move.is_collected = True
self.assertTrue(all(move.move_line_ids.mapped("is_collected")))
self.assertTrue(move.picked)
move.move_line_ids.is_collected = False
self.assertFalse(move.is_collected)
def test_ordered_quantity_survives_weighing(self):
picking = self._create_sale_picking(2.0)
move = picking.move_ids
move.move_line_ids.write({"quantity": 1.5, "picked": True})
self.assertEqual(move.product_uom_qty, 1.5)
self.assertEqual(move.sale_ordered_qty, 2.0)
self.assertEqual(move.move_line_ids.sale_ordered_qty, 2.0)
def test_batch_summary_ordered_quantity(self):
pickings = self._create_sale_picking(2.0) | self._create_sale_picking(3.0)
batch = self.env["stock.picking.batch"].create(
{"picking_ids": [Command.set(pickings.ids)]}
)
pickings[0].move_ids.move_line_ids.write({"quantity": 1.5, "picked": True})
batch._compute_summary_line_ids()
line = batch.summary_line_ids
self.assertEqual(line.qty_ordered, 5.0)
self.assertEqual(line.qty_demanded, 4.5)
def test_report_prints_every_delivery_slip(self):
pickings = self._create_sale_picking(2.0) | self._create_sale_picking(3.0)
batch = self.env["stock.picking.batch"].create(
{"picking_ids": [Command.set(pickings.ids)]}
)
html, _report_format = self.env["ir.actions.report"]._render_qweb_html(
"stock_picking_batch_collect.report_batch_deliveryslip", batch.ids
)
for picking in pickings:
self.assertIn(picking.name, html.decode())

View file

@ -9,6 +9,7 @@
<field name="product_id" readonly="1" context="{'display_default_code': False}"/> <field name="product_id" readonly="1" context="{'display_default_code': False}"/>
<field name="product_default_code" readonly="1" optional="hide"/> <field name="product_default_code" readonly="1" optional="hide"/>
<field name="product_uom_id" readonly="1" optional="hide"/> <field name="product_uom_id" readonly="1" optional="hide"/>
<field name="qty_ordered" readonly="1" optional="show"/>
<field name="qty_demanded" readonly="1"/> <field name="qty_demanded" readonly="1"/>
<field name="qty_done" readonly="1"/> <field name="qty_done" readonly="1"/>
<field name="qty_pending" readonly="1"/> <field name="qty_pending" readonly="1"/>
@ -30,6 +31,7 @@
context="{'display_default_code': False}"/> context="{'display_default_code': False}"/>
<field name="reference" readonly="1" optional="hide"/> <field name="reference" readonly="1" optional="hide"/>
<field name="picking_partner_id" readonly="1" optional="show"/> <field name="picking_partner_id" readonly="1" optional="show"/>
<field name="sale_ordered_qty" string="Ordered" readonly="1" optional="show"/>
<field name="quantity" string="Cant."/> <field name="quantity" string="Cant."/>
<!-- Loaded so `_onchange_quantity_manual` reaches the server. --> <!-- Loaded so `_onchange_quantity_manual` reaches the server. -->
<field name="picked" column_invisible="True"/> <field name="picked" column_invisible="True"/>
@ -78,6 +80,9 @@
<field name="product_default_code" readonly="1" optional="hide"/> <field name="product_default_code" readonly="1" optional="hide"/>
<field name="is_collected" widget="boolean_toggle" optional="show"/> <field name="is_collected" widget="boolean_toggle" optional="show"/>
</xpath> </xpath>
<xpath expr="//field[@name='quantity']" position="before">
<field name="sale_ordered_qty" readonly="1" optional="show"/>
</xpath>
<xpath expr="//field[@name='quantity']" position="after"> <xpath expr="//field[@name='quantity']" position="after">
<!-- Loaded so `_onchange_quantity_manual` reaches the server. --> <!-- Loaded so `_onchange_quantity_manual` reaches the server. -->
<field name="picked" column_invisible="True"/> <field name="picked" column_invisible="True"/>
@ -100,4 +105,15 @@
</field> </field>
</record> </record>
<record id="view_picking_move_tree_inherit_batch_collect" model="ir.ui.view">
<field name="name">stock_picking_batch.picking.move.list.collect</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock_picking_batch.view_picking_move_tree_inherited"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_uom_qty']" position="before">
<field name="sale_ordered_qty" readonly="1" optional="show"/>
</xpath>
</field>
</record>
</odoo> </odoo>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_form_inherit_collect" model="ir.ui.view">
<field name="name">stock.picking.form.collect</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='move_ids_without_package']/list/field[@name='product_uom_qty']" position="before">
<field name="sale_ordered_qty" readonly="1" optional="show"
column_invisible="parent.picking_type_code != 'outgoing'"/>
</xpath>
<xpath expr="//field[@name='move_ids_without_package']/list/field[@name='quantity']" position="after">
<field name="is_collected" widget="boolean_toggle" optional="show"
column_invisible="parent.state == 'draft'"
readonly="not move_line_ids or state in ('done', 'cancel')"/>
</xpath>
</field>
</record>
<record id="view_stock_move_line_detailed_operation_tree_inherit_collect" model="ir.ui.view">
<field name="name">stock.move.line.operations.list.collect</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_detailed_operation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='quantity']" position="before">
<field name="sale_ordered_qty" readonly="1" optional="show"
column_invisible="context.get('picking_code') == 'incoming'"/>
</xpath>
<xpath expr="//field[@name='quantity']" position="after">
<field name="is_collected" widget="boolean_toggle" optional="show"
readonly="state in ('done', 'cancel')"/>
</xpath>
</field>
</record>
<record id="view_stock_move_line_operation_tree_inherit_collect" model="ir.ui.view">
<field name="name">stock.move.line.operations.list.popup.collect</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='quantity']" position="after">
<field name="is_collected" widget="boolean_toggle"
readonly="state in ('done', 'cancel')"/>
</xpath>
</field>
</record>
<record id="view_picking_type_form_inherit_collect" model="ir.ui.view">
<field name="name">stock.picking.type.form.collect</field>
<field name="model">stock.picking.type</field>
<field name="inherit_id" ref="stock.view_picking_type_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='create_backorder']" position="after">
<field name="collected_check_required"/>
</xpath>
</field>
</record>
</odoo>