[ADD] stock_picking_batch_custom: product summary

This commit is contained in:
snt 2026-03-05 20:29:02 +01:00
parent 9c14e1dc1a
commit ad8b759643
11 changed files with 348 additions and 10 deletions

View file

@ -13,6 +13,8 @@
"stock_picking_batch", "stock_picking_batch",
], ],
"data": [ "data": [
"security/ir.model.access.csv",
"views/stock_move_line_views.xml", "views/stock_move_line_views.xml",
"views/stock_picking_batch_views.xml",
], ],
} }

View file

@ -1 +1,2 @@
from . import stock_move_line # noqa: F401 from . import stock_move_line # noqa: F401
from . import stock_picking_batch # noqa: F401

View file

@ -10,8 +10,14 @@ class StockMoveLine(models.Model):
product_categ_id = fields.Many2one( product_categ_id = fields.Many2one(
comodel_name="product.category", comodel_name="product.category",
string="Product Category", string="Product Category (Batch)",
related="product_id.categ_id", related="product_id.categ_id",
store=True, store=True,
readonly=True, readonly=True,
) )
is_collected = fields.Boolean(
string="Collected",
default=False,
copy=False,
)

View file

@ -0,0 +1,159 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from collections import defaultdict
from odoo import api
from odoo import fields
from odoo import models
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=False,
)
@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:
previous_collected = {
line.product_id.id: line.is_collected for line in batch.summary_line_ids
}
aggregates = defaultdict(
lambda: {
"product_id": False,
"product_uom_id": False,
"qty_demanded": 0.0,
"qty_done": 0.0,
}
)
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"
)
done = move.product_uom._compute_quantity(
move.quantity, product_uom, rounding_method="HALF-UP"
)
entry = aggregates[product.id]
entry["product_id"] = product.id
entry["product_uom_id"] = product_uom.id
entry["qty_demanded"] += demand
entry["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():
products_in_totals.add(product_id)
existing_line = existing_by_product.get(product_id)
values["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": values["is_collected"],
},
)
)
else:
commands.append(fields.Command.create(values))
obsolete_lines = [
fields.Command.unlink(line.id)
for line in batch.summary_line_ids
if line.product_id.id not in products_in_totals
]
batch.summary_line_ids = commands + obsolete_lines
class StockPickingBatchSummaryLine(models.Model):
_name = "stock.picking.batch.summary.line"
_description = "Batch Product Summary Line"
_order = "product_categ_id, product_id"
batch_id = fields.Many2one(
comodel_name="stock.picking.batch",
string="Batch",
ondelete="cascade",
required=True,
index=True,
)
product_id = fields.Many2one(
comodel_name="product.product",
string="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_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

@ -1,11 +1,14 @@
Este módulo añade dos columnas opcionales en las operaciones detalladas de los Este módulo amplía las operaciones detalladas y añade un resumen por producto
lotes de picking: en los lotes de picking:
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén - ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
pueda identificar rápidamente el cliente/proveedor asociado. identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para permitir ordenación y - ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
agrupación por categoría. - ``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.
Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote,
lote, de modo que el usuario puede activarlas desde el selector de columnas sin de modo que el usuario puede activarlas desde el selector de columnas sin
cargar la vista por defecto. recargar la vista por defecto.

View file

@ -6,5 +6,9 @@ Uso
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor. - **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría. - **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Ordena o agrupa por la columna de categoría según convenga. 3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.

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

View file

@ -0,0 +1,127 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests.common import SavepointCase
class TestBatchSummary(SavepointCase):
@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.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": uom.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_cache()
self.batch._compute_summary_line_ids()
# 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)

View file

@ -11,6 +11,9 @@
<xpath expr="//field[@name='picking_id']" position="after"> <xpath expr="//field[@name='picking_id']" position="after">
<field name="picking_partner_id" optional="hide"/> <field name="picking_partner_id" optional="hide"/>
</xpath> </xpath>
<xpath expr="//field[@name='quantity']" position="after">
<field name="is_collected" optional="show" widget="boolean_toggle"/>
</xpath>
</field> </field>
</record> </record>
</odoo> </odoo>

View file

@ -0,0 +1,31 @@
<?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" editable="bottom" default_order="product_categ_id,product_id">
<field name="product_id" readonly="1"/>
<field name="product_categ_id" 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"/>
</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="//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_custom.view_stock_picking_batch_summary_line_tree'}"/>
</page>
</xpath>
</field>
</record>
</odoo>