From 9c81cb986d858a9730c7cf3168d9e18e4f18f423 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sun, 4 Oct 2026 14:32:38 +0200 Subject: [PATCH] [IMP] stock_picking_batch_collect: collected checks on transfers, ordered quantity, delivery slips report - The collected checks also apply to transfers outside a batch, opt-in per operation type ("Require Collected Lines"), with the batch restrictions of the settings. A per-product toggle in the transfer's Operations tab collects all its lines. - Read-only ordered quantity from the sale line on every batch and transfer view, since the weighed demand overwrites it. Adds a dependency on sale_stock; a migration fills it in the summary of open batches. - "Delivery Slips" report on the batch: all its delivery slips in one PDF. Co-Authored-By: Claude Opus 5.5 --- .github/copilot-instructions.md | 5 +- stock_picking_batch_collect/README.rst | 33 +++++ stock_picking_batch_collect/__init__.py | 1 + stock_picking_batch_collect/__manifest__.py | 7 +- stock_picking_batch_collect/i18n/es.po | 49 +++++++ stock_picking_batch_collect/i18n/eu.po | 49 +++++++ .../migrations/18.0.2.1.0/post-migration.py | 15 ++ .../models/__init__.py | 2 + .../models/stock_backorder_confirmation.py | 12 +- .../models/stock_move.py | 55 +++++++ .../models/stock_move_line.py | 18 +++ .../models/stock_picking.py | 70 +++++++-- .../models/stock_picking_batch.py | 47 +++--- .../models/stock_picking_type.py | 16 ++ .../readme/CONFIGURE.rst | 11 ++ .../readme/DESCRIPTION.rst | 9 ++ stock_picking_batch_collect/readme/USAGE.rst | 13 ++ .../report/__init__.py | 1 + .../report/report_batch_deliveryslip.py | 25 ++++ .../report/report_batch_deliveryslip.xml | 19 +++ stock_picking_batch_collect/tests/__init__.py | 1 + .../tests/test_picking_collected.py | 137 ++++++++++++++++++ .../views/stock_picking_batch_views.xml | 16 ++ .../views/stock_picking_views.xml | 58 ++++++++ 24 files changed, 630 insertions(+), 39 deletions(-) create mode 100644 stock_picking_batch_collect/migrations/18.0.2.1.0/post-migration.py create mode 100644 stock_picking_batch_collect/models/stock_move.py create mode 100644 stock_picking_batch_collect/models/stock_picking_type.py create mode 100644 stock_picking_batch_collect/report/__init__.py create mode 100644 stock_picking_batch_collect/report/report_batch_deliveryslip.py create mode 100644 stock_picking_batch_collect/report/report_batch_deliveryslip.xml create mode 100644 stock_picking_batch_collect/tests/test_picking_collected.py create mode 100644 stock_picking_batch_collect/views/stock_picking_views.xml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0f33715..22d616b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -235,7 +235,7 @@ addons-cm/ **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) - [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) @@ -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_origin` (OCA) sustituido por `product_origin_char` (custom). - **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` (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 diff --git a/stock_picking_batch_collect/README.rst b/stock_picking_batch_collect/README.rst index 75a70b6..ab69239 100644 --- a/stock_picking_batch_collect/README.rst +++ b/stock_picking_batch_collect/README.rst @@ -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. - Restricciones de validación configurables por compañía: el lote no se puede 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 (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). - *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 -------- @@ -108,6 +128,19 @@ Uso 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. +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 ============== diff --git a/stock_picking_batch_collect/__init__.py b/stock_picking_batch_collect/__init__.py index 4a5c3e3..7efee8a 100644 --- a/stock_picking_batch_collect/__init__.py +++ b/stock_picking_batch_collect/__init__.py @@ -1,2 +1,3 @@ from . import models # noqa: F401 +from . import report # noqa: F401 from .hooks import pre_init_hook # noqa: F401 diff --git a/stock_picking_batch_collect/__manifest__.py b/stock_picking_batch_collect/__manifest__.py index 7e61eac..ae2b5f3 100644 --- a/stock_picking_batch_collect/__manifest__.py +++ b/stock_picking_batch_collect/__manifest__.py @@ -2,7 +2,7 @@ # 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", + "version": "18.0.2.1.0", "category": "Warehouse", "summary": "Collect batch operations: operator view, extra columns and product summary", "author": "Odoo Community Association (OCA), Criptomart", @@ -10,6 +10,9 @@ "website": "https://github.com/Criptomart", "license": "AGPL-3", "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 # re-reserved by the scheduler. "stock_move_manual_quantity", @@ -19,6 +22,8 @@ "security/ir.model.access.csv", "views/res_config_settings_views.xml", "views/stock_picking_batch_views.xml", + "views/stock_picking_views.xml", + "report/report_batch_deliveryslip.xml", ], "assets": { "web.assets_backend": [ diff --git a/stock_picking_batch_collect/i18n/es.po b/stock_picking_batch_collect/i18n/es.po index 4bad2cb..21f6002 100644 --- a/stock_picking_batch_collect/i18n/es.po +++ b/stock_picking_batch_collect/i18n/es.po @@ -62,6 +62,7 @@ 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__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 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 msgid "Basket Assembly" 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" diff --git a/stock_picking_batch_collect/i18n/eu.po b/stock_picking_batch_collect/i18n/eu.po index 62cd2c9..b0de814 100644 --- a/stock_picking_batch_collect/i18n/eu.po +++ b/stock_picking_batch_collect/i18n/eu.po @@ -62,6 +62,7 @@ msgid "Batch Transfer" msgstr "Lote-transferentzia" #. 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_picking_batch_summary_line__is_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 msgid "Basket Assembly" 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" diff --git a/stock_picking_batch_collect/migrations/18.0.2.1.0/post-migration.py b/stock_picking_batch_collect/migrations/18.0.2.1.0/post-migration.py new file mode 100644 index 0000000..d46ffbc --- /dev/null +++ b/stock_picking_batch_collect/migrations/18.0.2.1.0/post-migration.py @@ -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"]) diff --git a/stock_picking_batch_collect/models/__init__.py b/stock_picking_batch_collect/models/__init__.py index 5b85812..08ec35b 100644 --- a/stock_picking_batch_collect/models/__init__.py +++ b/stock_picking_batch_collect/models/__init__.py @@ -1,6 +1,8 @@ from . import res_company # noqa: F401 from . import res_config_settings # 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_picking # noqa: F401 from . import stock_picking_batch # noqa: F401 +from . import stock_picking_type # noqa: F401 diff --git a/stock_picking_batch_collect/models/stock_backorder_confirmation.py b/stock_picking_batch_collect/models/stock_backorder_confirmation.py index 2236e4f..6c2067a 100644 --- a/stock_picking_batch_collect/models/stock_backorder_confirmation.py +++ b/stock_picking_batch_collect/models/stock_backorder_confirmation.py @@ -7,17 +7,15 @@ from odoo import models class StockBackorderConfirmation(models.TransientModel): _inherit = "stock.backorder.confirmation" - def _check_batch_summary_lines(self): - pickings = self.env["stock.picking"].browse( + def _check_collected_products(self): + 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) + )._check_collected_products() def process(self): - self._check_batch_summary_lines() + self._check_collected_products() return super().process() def process_cancel_backorder(self): - self._check_batch_summary_lines() + self._check_collected_products() return super().process_cancel_backorder() diff --git a/stock_picking_batch_collect/models/stock_move.py b/stock_picking_batch_collect/models/stock_move.py new file mode 100644 index 0000000..14e4554 --- /dev/null +++ b/stock_picking_batch_collect/models/stock_move.py @@ -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 diff --git a/stock_picking_batch_collect/models/stock_move_line.py b/stock_picking_batch_collect/models/stock_move_line.py index c87320a..012813f 100644 --- a/stock_picking_batch_collect/models/stock_move_line.py +++ b/stock_picking_batch_collect/models/stock_move_line.py @@ -1,6 +1,7 @@ # 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 @@ -35,6 +36,23 @@ class StockMoveLine(models.Model): 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): res = super().write(vals) if vals.get("is_collected"): diff --git a/stock_picking_batch_collect/models/stock_picking.py b/stock_picking_batch_collect/models/stock_picking.py index 1cb06ef..ed1b66a 100644 --- a/stock_picking_batch_collect/models/stock_picking.py +++ b/stock_picking_batch_collect/models/stock_picking.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import models +from odoo.exceptions import UserError class StockPicking(models.Model): @@ -19,21 +20,68 @@ class StockPicking(models.Model): if res is not True: return res - self._check_batch_collected_products() + self._check_collected_products() 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: 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 batch_pickings._get_open_move_lines("processed"): + 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, ) - if not processed_move_lines: - continue - batch._check_all_products_collected(batch_pickings) + ) diff --git a/stock_picking_batch_collect/models/stock_picking_batch.py b/stock_picking_batch_collect/models/stock_picking_batch.py index 553796a..d362c42 100644 --- a/stock_picking_batch_collect/models/stock_picking_batch.py +++ b/stock_picking_batch_collect/models/stock_picking_batch.py @@ -25,10 +25,12 @@ class StockPickingBatch(models.Model): "move_line_ids.move_id.quantity", "move_line_ids.move_id.product_uom", "move_line_ids.move_id.state", + "move_line_ids.move_id.sale_line_id.product_uom_qty", ) def _compute_summary_line_ids(self): """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. - Done: move.quantity converted to product UoM. - Pending: demand - done. @@ -49,6 +51,7 @@ class StockPickingBatch(models.Model): } # Use regular dict to avoid accidental creation of empty entries aggregates = {} + counted_sale_lines = set() # Demand per move (count once per move) for move in batch.move_ids: @@ -63,10 +66,22 @@ class StockPickingBatch(models.Model): aggregates[product.id] = { "product_id": product.id, "product_uom_id": product_uom.id, + "qty_ordered": 0.0, "qty_demanded": 0.0, "qty_done": 0.0, } 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 for line in batch.move_line_ids: @@ -84,6 +99,7 @@ class StockPickingBatch(models.Model): aggregates[product.id] = { "product_id": product.id, "product_uom_id": product_uom.id, + "qty_ordered": 0.0, "qty_demanded": 0.0, "qty_done": 0.0, } @@ -108,6 +124,7 @@ class StockPickingBatch(models.Model): existing_line.id, { "product_uom_id": values["product_uom_id"], + "qty_ordered": values["qty_ordered"], "qty_demanded": values["qty_demanded"], "qty_done": values["qty_done"], "is_collected": is_collected, @@ -122,6 +139,7 @@ class StockPickingBatch(models.Model): { "product_id": values["product_id"], "product_uom_id": values["product_uom_id"], + "qty_ordered": values["qty_ordered"], "qty_demanded": values["qty_demanded"], "qty_done": values["qty_done"], "is_collected": is_collected, @@ -151,30 +169,16 @@ class StockPickingBatch(models.Model): 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") - ) + processed_products = batch_pickings._get_open_move_lines( + "processed" + ).product_id 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) 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) + return batch_pickings._get_not_collected_move_lines(scope) def _check_all_products_collected(self, pickings=None): """Validate collected restrictions based on tab configuration.""" @@ -285,6 +289,11 @@ class StockPickingBatchSummaryLine(models.Model): string="Unit of Measure", required=True, ) + qty_ordered = fields.Float( + string="Ordered Quantity", + digits="Product Unit of Measure", + default=0.0, + ) qty_demanded = fields.Float( string="Demanded Quantity", digits="Product Unit of Measure", diff --git a/stock_picking_batch_collect/models/stock_picking_type.py b/stock_picking_batch_collect/models/stock_picking_type.py new file mode 100644 index 0000000..9b5ff5b --- /dev/null +++ b/stock_picking_batch_collect/models/stock_picking_type.py @@ -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.", + ) diff --git a/stock_picking_batch_collect/readme/CONFIGURE.rst b/stock_picking_batch_collect/readme/CONFIGURE.rst index bda97fc..100fd1a 100644 --- a/stock_picking_batch_collect/readme/CONFIGURE.rst +++ b/stock_picking_batch_collect/readme/CONFIGURE.rst @@ -21,6 +21,17 @@ Cada restricción tiene un alcance: mayor que cero (por defecto). - *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 -------- diff --git a/stock_picking_batch_collect/readme/DESCRIPTION.rst b/stock_picking_batch_collect/readme/DESCRIPTION.rst index dc348e7..74012d7 100644 --- a/stock_picking_batch_collect/readme/DESCRIPTION.rst +++ b/stock_picking_batch_collect/readme/DESCRIPTION.rst @@ -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. - Restricciones de validación configurables por compañía: el lote no se puede 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 (salvo ``product_default_code``, oculta por defecto), de modo que el usuario diff --git a/stock_picking_batch_collect/readme/USAGE.rst b/stock_picking_batch_collect/readme/USAGE.rst index 73eea25..8caafd5 100644 --- a/stock_picking_batch_collect/readme/USAGE.rst +++ b/stock_picking_batch_collect/readme/USAGE.rst @@ -27,3 +27,16 @@ Uso 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. + +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. diff --git a/stock_picking_batch_collect/report/__init__.py b/stock_picking_batch_collect/report/__init__.py new file mode 100644 index 0000000..dfbb06d --- /dev/null +++ b/stock_picking_batch_collect/report/__init__.py @@ -0,0 +1 @@ +from . import report_batch_deliveryslip # noqa: F401 diff --git a/stock_picking_batch_collect/report/report_batch_deliveryslip.py b/stock_picking_batch_collect/report/report_batch_deliveryslip.py new file mode 100644 index 0000000..c1e57f4 --- /dev/null +++ b/stock_picking_batch_collect/report/report_batch_deliveryslip.py @@ -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, + } diff --git a/stock_picking_batch_collect/report/report_batch_deliveryslip.xml b/stock_picking_batch_collect/report/report_batch_deliveryslip.xml new file mode 100644 index 0000000..91b6a74 --- /dev/null +++ b/stock_picking_batch_collect/report/report_batch_deliveryslip.xml @@ -0,0 +1,19 @@ + + + + Delivery Slips + stock.picking.batch + qweb-pdf + stock_picking_batch_collect.report_batch_deliveryslip + stock_picking_batch_collect.report_batch_deliveryslip + 'Delivery Slips - %s' % object.name + + report + + + + diff --git a/stock_picking_batch_collect/tests/__init__.py b/stock_picking_batch_collect/tests/__init__.py index a586ed8..ba5d668 100644 --- a/stock_picking_batch_collect/tests/__init__.py +++ b/stock_picking_batch_collect/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_batch_summary # noqa: F401 from . import test_collected_picked # noqa: F401 +from . import test_picking_collected # noqa: F401 diff --git a/stock_picking_batch_collect/tests/test_picking_collected.py b/stock_picking_batch_collect/tests/test_picking_collected.py new file mode 100644 index 0000000..30bfbc3 --- /dev/null +++ b/stock_picking_batch_collect/tests/test_picking_collected.py @@ -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()) diff --git a/stock_picking_batch_collect/views/stock_picking_batch_views.xml b/stock_picking_batch_collect/views/stock_picking_batch_views.xml index 38c8d70..5918306 100644 --- a/stock_picking_batch_collect/views/stock_picking_batch_views.xml +++ b/stock_picking_batch_collect/views/stock_picking_batch_views.xml @@ -9,6 +9,7 @@ + @@ -30,6 +31,7 @@ context="{'display_default_code': False}"/> + @@ -78,6 +80,9 @@ + + + @@ -100,4 +105,15 @@ + + stock_picking_batch.picking.move.list.collect + stock.move + + + + + + + + diff --git a/stock_picking_batch_collect/views/stock_picking_views.xml b/stock_picking_batch_collect/views/stock_picking_views.xml new file mode 100644 index 0000000..80ef434 --- /dev/null +++ b/stock_picking_batch_collect/views/stock_picking_views.xml @@ -0,0 +1,58 @@ + + + + stock.picking.form.collect + stock.picking + + + + + + + + + + + + + stock.move.line.operations.list.collect + stock.move.line + + + + + + + + + + + + + stock.move.line.operations.list.popup.collect + stock.move.line + + + + + + + + + + stock.picking.type.form.collect + stock.picking.type + + + + + + + + -- 2.39.5