[ADD] stock_picking_batch_custom: product summary
This commit is contained in:
parent
9c14e1dc1a
commit
ad8b759643
11 changed files with 348 additions and 10 deletions
|
|
@ -13,6 +13,8 @@
|
|||
"stock_picking_batch",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/stock_move_line_views.xml",
|
||||
"views/stock_picking_batch_views.xml",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
from . import stock_move_line # noqa: F401
|
||||
from . import stock_picking_batch # noqa: F401
|
||||
|
|
|
|||
|
|
@ -10,8 +10,14 @@ class StockMoveLine(models.Model):
|
|||
|
||||
product_categ_id = fields.Many2one(
|
||||
comodel_name="product.category",
|
||||
string="Product Category",
|
||||
string="Product Category (Batch)",
|
||||
related="product_id.categ_id",
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
is_collected = fields.Boolean(
|
||||
string="Collected",
|
||||
default=False,
|
||||
copy=False,
|
||||
)
|
||||
|
|
|
|||
159
stock_picking_batch_custom/models/stock_picking_batch.py
Normal file
159
stock_picking_batch_custom/models/stock_picking_batch.py
Normal 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
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
Este módulo añade dos columnas opcionales en las operaciones detalladas de los
|
||||
lotes de picking:
|
||||
Este módulo amplía las operaciones detalladas y añade un resumen por producto
|
||||
en los lotes de picking:
|
||||
|
||||
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
|
||||
pueda identificar rápidamente el cliente/proveedor asociado.
|
||||
- ``product_categ_id`` (Categoría de producto) para permitir ordenación y
|
||||
agrupación por categoría.
|
||||
identifique rápido el cliente/proveedor.
|
||||
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
|
||||
- ``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
|
||||
lote, de modo que el usuario puede activarlas desde el selector de columnas sin
|
||||
cargar la vista por defecto.
|
||||
Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote,
|
||||
de modo que el usuario puede activarlas desde el selector de columnas sin
|
||||
recargar la vista por defecto.
|
||||
|
|
|
|||
|
|
@ -6,5 +6,9 @@ Uso
|
|||
|
||||
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
|
||||
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
|
||||
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
|
||||
|
||||
3. 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.
|
||||
|
|
|
|||
2
stock_picking_batch_custom/security/ir.model.access.csv
Normal file
2
stock_picking_batch_custom/security/ir.model.access.csv
Normal 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
|
||||
|
0
stock_picking_batch_custom/tests/__init__.py
Normal file
0
stock_picking_batch_custom/tests/__init__.py
Normal file
127
stock_picking_batch_custom/tests/test_batch_summary.py
Normal file
127
stock_picking_batch_custom/tests/test_batch_summary.py
Normal 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)
|
||||
|
|
@ -11,6 +11,9 @@
|
|||
<xpath expr="//field[@name='picking_id']" position="after">
|
||||
<field name="picking_partner_id" optional="hide"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='quantity']" position="after">
|
||||
<field name="is_collected" optional="show" widget="boolean_toggle"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue