Compare commits
No commits in common. "3eeca66551d81e8e4bff3bbfb166e6efba73a5ef" and "eaedf0b421189e74b427ddbb7344dd5968599e18" have entirely different histories.
3eeca66551
...
eaedf0b421
13 changed files with 80 additions and 690 deletions
|
|
@ -1,66 +1,13 @@
|
||||||
============================
|
===============================
|
||||||
Stock Picking Batch Custom
|
Stock Picking Batch Custom
|
||||||
============================
|
===============================
|
||||||
|
|
||||||
Visión general
|
.. contents::
|
||||||
==============
|
:local:
|
||||||
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 identificar cliente/proveedor.
|
.. include:: readme/DESCRIPTION.rst
|
||||||
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
|
.. include:: readme/INSTALL.rst
|
||||||
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se ha
|
.. include:: readme/CONFIGURE.rst
|
||||||
recolectado.
|
.. include:: readme/USAGE.rst
|
||||||
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
|
.. include:: readme/CONTRIBUTORS.rst
|
||||||
pendiente), categoría y el check de recogido consolidado.
|
.. include:: readme/CREDITS.rst
|
||||||
|
|
||||||
Instalación
|
|
||||||
===========
|
|
||||||
|
|
||||||
Actualizar o instalar el módulo:
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
|
|
||||||
|
|
||||||
Configuración
|
|
||||||
=============
|
|
||||||
|
|
||||||
No requiere configuración adicional. Para usar las columnas:
|
|
||||||
|
|
||||||
- Abrir un **Lote de picking**.
|
|
||||||
- Ir a la pestaña **Detailed Operations**.
|
|
||||||
- Abrir el **selector de columnas** y activar *Partner*, *Product Category* y *Collected* según necesidad.
|
|
||||||
|
|
||||||
Uso
|
|
||||||
===
|
|
||||||
|
|
||||||
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
|
|
||||||
2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
|
|
||||||
|
|
||||||
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
|
|
||||||
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
|
|
||||||
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
|
|
||||||
|
|
||||||
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
|
|
||||||
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
|
|
||||||
|
|
||||||
4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
|
|
||||||
|
|
||||||
Contribuidores
|
|
||||||
==============
|
|
||||||
|
|
||||||
* Criptomart
|
|
||||||
|
|
||||||
Créditos
|
|
||||||
========
|
|
||||||
|
|
||||||
Autor
|
|
||||||
-----
|
|
||||||
|
|
||||||
* Criptomart
|
|
||||||
|
|
||||||
Financiador
|
|
||||||
-----------
|
|
||||||
|
|
||||||
* Elika Bilbo
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@
|
||||||
"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",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
from . import stock_move_line # noqa: F401
|
from . import stock_move_line # noqa: F401
|
||||||
from . import stock_picking_batch # noqa: F401
|
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,8 @@ 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 (Batch)",
|
string="Product Category",
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
# 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 StockPickingBatch(models.Model):
|
|
||||||
_inherit = "stock.picking.batch"
|
|
||||||
|
|
||||||
summary_line_ids = fields.One2many(
|
|
||||||
comodel_name="stock.picking.batch.summary.line",
|
|
||||||
inverse_name="batch_id",
|
|
||||||
string="Product Summary",
|
|
||||||
compute="_compute_summary_line_ids",
|
|
||||||
store=True,
|
|
||||||
readonly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.depends(
|
|
||||||
"move_line_ids.move_id.product_id",
|
|
||||||
"move_line_ids.move_id.product_uom_qty",
|
|
||||||
"move_line_ids.move_id.quantity",
|
|
||||||
"move_line_ids.move_id.product_uom",
|
|
||||||
"move_line_ids.move_id.state",
|
|
||||||
)
|
|
||||||
def _compute_summary_line_ids(self):
|
|
||||||
"""Aggregate move quantities per product and keep collected flag.
|
|
||||||
|
|
||||||
- Demand: move.product_uom_qty converted to product UoM.
|
|
||||||
- Done: move.quantity converted to product UoM.
|
|
||||||
- Pending: demand - done.
|
|
||||||
- Keep is_collected value if product already present.
|
|
||||||
- Skip cancelled moves.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for batch in self:
|
|
||||||
# Base: limpiar si no hay líneas
|
|
||||||
if not batch.move_line_ids:
|
|
||||||
batch.summary_line_ids = [fields.Command.clear()]
|
|
||||||
continue
|
|
||||||
|
|
||||||
previous_collected = {
|
|
||||||
line.product_id.id: line.is_collected
|
|
||||||
for line in batch.summary_line_ids
|
|
||||||
if line.product_id
|
|
||||||
}
|
|
||||||
# Use regular dict to avoid accidental creation of empty entries
|
|
||||||
aggregates = {}
|
|
||||||
|
|
||||||
# Demand per move (count once per move)
|
|
||||||
for move in batch.move_ids:
|
|
||||||
if move.state == "cancel" or not move.product_id:
|
|
||||||
continue
|
|
||||||
product = move.product_id
|
|
||||||
product_uom = product.uom_id
|
|
||||||
demand = move.product_uom._compute_quantity(
|
|
||||||
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
|
|
||||||
)
|
|
||||||
if product.id not in aggregates:
|
|
||||||
aggregates[product.id] = {
|
|
||||||
"product_id": product.id,
|
|
||||||
"product_uom_id": product_uom.id,
|
|
||||||
"qty_demanded": 0.0,
|
|
||||||
"qty_done": 0.0,
|
|
||||||
}
|
|
||||||
aggregates[product.id]["qty_demanded"] += demand
|
|
||||||
|
|
||||||
# Done per move line
|
|
||||||
for line in batch.move_line_ids:
|
|
||||||
move = line.move_id
|
|
||||||
if move and move.state == "cancel":
|
|
||||||
continue
|
|
||||||
product = line.product_id
|
|
||||||
if not product:
|
|
||||||
continue
|
|
||||||
product_uom = product.uom_id
|
|
||||||
done = line.product_uom_id._compute_quantity(
|
|
||||||
line.quantity, product_uom, rounding_method="HALF-UP"
|
|
||||||
)
|
|
||||||
if product.id not in aggregates:
|
|
||||||
aggregates[product.id] = {
|
|
||||||
"product_id": product.id,
|
|
||||||
"product_uom_id": product_uom.id,
|
|
||||||
"qty_demanded": 0.0,
|
|
||||||
"qty_done": 0.0,
|
|
||||||
}
|
|
||||||
aggregates[product.id]["qty_done"] += done
|
|
||||||
|
|
||||||
commands = []
|
|
||||||
products_in_totals = set()
|
|
||||||
existing_by_product = {
|
|
||||||
line.product_id.id: line for line in batch.summary_line_ids
|
|
||||||
}
|
|
||||||
|
|
||||||
for product_id, values in aggregates.items():
|
|
||||||
# Double-check: skip if product_id is not valid
|
|
||||||
if not product_id or not values.get("product_id"):
|
|
||||||
continue
|
|
||||||
products_in_totals.add(product_id)
|
|
||||||
existing_line = existing_by_product.get(product_id)
|
|
||||||
is_collected = previous_collected.get(product_id, False)
|
|
||||||
if existing_line:
|
|
||||||
commands.append(
|
|
||||||
fields.Command.update(
|
|
||||||
existing_line.id,
|
|
||||||
{
|
|
||||||
"product_uom_id": values["product_uom_id"],
|
|
||||||
"qty_demanded": values["qty_demanded"],
|
|
||||||
"qty_done": values["qty_done"],
|
|
||||||
"is_collected": is_collected,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Ensure all required fields are present before create
|
|
||||||
if values["product_id"] and values["product_uom_id"]:
|
|
||||||
commands.append(
|
|
||||||
fields.Command.create(
|
|
||||||
{
|
|
||||||
"product_id": values["product_id"],
|
|
||||||
"product_uom_id": values["product_uom_id"],
|
|
||||||
"qty_demanded": values["qty_demanded"],
|
|
||||||
"qty_done": values["qty_done"],
|
|
||||||
"is_collected": is_collected,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
obsolete_lines = [
|
|
||||||
fields.Command.unlink(line.id)
|
|
||||||
for line in batch.summary_line_ids
|
|
||||||
if line.product_id.id not in products_in_totals
|
|
||||||
]
|
|
||||||
|
|
||||||
if commands or obsolete_lines:
|
|
||||||
batch.summary_line_ids = commands + obsolete_lines
|
|
||||||
else:
|
|
||||||
batch.summary_line_ids = [fields.Command.clear()]
|
|
||||||
|
|
||||||
|
|
||||||
class StockPickingBatchSummaryLine(models.Model):
|
|
||||||
_name = "stock.picking.batch.summary.line"
|
|
||||||
_description = "Batch Product Summary Line"
|
|
||||||
_order = "product_categ_id, product_id"
|
|
||||||
|
|
||||||
_sql_constraints = [
|
|
||||||
(
|
|
||||||
"product_required",
|
|
||||||
"CHECK(product_id IS NOT NULL)",
|
|
||||||
"Product is required for summary lines.",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
batch_id = fields.Many2one(
|
|
||||||
comodel_name="stock.picking.batch",
|
|
||||||
ondelete="cascade",
|
|
||||||
required=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
product_id = fields.Many2one(
|
|
||||||
comodel_name="product.product",
|
|
||||||
required=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
product_categ_id = fields.Many2one(
|
|
||||||
comodel_name="product.category",
|
|
||||||
string="Product Category",
|
|
||||||
related="product_id.categ_id",
|
|
||||||
store=True,
|
|
||||||
readonly=True,
|
|
||||||
)
|
|
||||||
product_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,14 +1,11 @@
|
||||||
Este módulo amplía las operaciones detalladas y añade un resumen por producto
|
Este módulo añade dos columnas opcionales en las operaciones detalladas de los
|
||||||
en los lotes de picking:
|
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
|
||||||
identifique rápido el cliente/proveedor.
|
pueda identificar rápidamente el cliente/proveedor asociado.
|
||||||
- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
|
- ``product_categ_id`` (Categoría de producto) para permitir ordenación y
|
||||||
- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
|
agrupación por categoría.
|
||||||
ha recolectado.
|
|
||||||
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
|
|
||||||
pendiente), categoría y el check de recogido consolidado.
|
|
||||||
|
|
||||||
Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote,
|
Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del
|
||||||
de modo que el usuario puede activarlas desde el selector de columnas sin
|
lote, de modo que el usuario puede activarlas desde el selector de columnas sin
|
||||||
recargar la vista por defecto.
|
cargar la vista por defecto.
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,5 @@ 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. Pestaña **Product Summary**: consulta los totales por producto (demandado,
|
3. Ordena o agrupa por la columna de categoría según convenga.
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
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 +0,0 @@
|
||||||
from . import test_batch_summary # noqa: F401
|
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
# Copyright 2026 Criptomart
|
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
||||||
|
|
||||||
from odoo.tests import tagged
|
|
||||||
from odoo.tests.common import TransactionCase
|
|
||||||
|
|
||||||
|
|
||||||
@tagged("-at_install", "post_install")
|
|
||||||
class TestBatchSummary(TransactionCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
super().setUpClass()
|
|
||||||
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
|
|
||||||
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
|
|
||||||
cls.location_src = cls.env.ref("stock.stock_location_stock")
|
|
||||||
cls.location_dest = cls.env.ref("stock.stock_location_customers")
|
|
||||||
cls.picking_type = cls.env.ref("stock.picking_type_out")
|
|
||||||
|
|
||||||
cls.product = cls.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Product",
|
|
||||||
"uom_id": cls.uom_unit.id,
|
|
||||||
"uom_po_id": cls.uom_unit.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
cls.batch = cls.env["stock.picking.batch"].create(
|
|
||||||
{"name": "Batch Test", "picking_type_id": cls.picking_type.id}
|
|
||||||
)
|
|
||||||
|
|
||||||
cls.picking1 = cls.env["stock.picking"].create(
|
|
||||||
{
|
|
||||||
"picking_type_id": cls.picking_type.id,
|
|
||||||
"location_id": cls.location_src.id,
|
|
||||||
"location_dest_id": cls.location_dest.id,
|
|
||||||
"batch_id": cls.batch.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
cls.picking2 = cls.env["stock.picking"].create(
|
|
||||||
{
|
|
||||||
"picking_type_id": cls.picking_type.id,
|
|
||||||
"location_id": cls.location_src.id,
|
|
||||||
"location_dest_id": cls.location_dest.id,
|
|
||||||
"batch_id": cls.batch.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Helpers
|
|
||||||
def _add_move(self, picking, qty_demanded, qty_done, uom):
|
|
||||||
move = self.env["stock.move"].create(
|
|
||||||
{
|
|
||||||
"name": self.product.name,
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_qty": qty_demanded,
|
|
||||||
"product_uom": uom.id,
|
|
||||||
"picking_id": picking.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if qty_done:
|
|
||||||
self.env["stock.move.line"].create(
|
|
||||||
{
|
|
||||||
"move_id": move.id,
|
|
||||||
"picking_id": picking.id,
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_id": self.product.uom_id.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
"quantity": qty_done,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return move
|
|
||||||
|
|
||||||
def _recompute_batch(self):
|
|
||||||
self.batch.invalidate_recordset()
|
|
||||||
self.batch._compute_summary_line_ids()
|
|
||||||
|
|
||||||
def _create_batch_with_pickings(self):
|
|
||||||
batch = self.env["stock.picking.batch"].create(
|
|
||||||
{"name": "Batch Flow", "picking_type_id": self.picking_type.id}
|
|
||||||
)
|
|
||||||
picking = self.env["stock.picking"].create(
|
|
||||||
{
|
|
||||||
"picking_type_id": self.picking_type.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
move = self.env["stock.move"].create(
|
|
||||||
{
|
|
||||||
"name": self.product.name,
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_qty": 2.0,
|
|
||||||
"product_uom": self.uom_unit.id,
|
|
||||||
"picking_id": picking.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.env["stock.move.line"].create(
|
|
||||||
{
|
|
||||||
"move_id": move.id,
|
|
||||||
"picking_id": picking.id,
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_id": self.uom_unit.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
"quantity": 2.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
picking.batch_id = batch.id
|
|
||||||
return batch
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
def test_totals_and_pending_with_conversion(self):
|
|
||||||
"""Totals aggregate per product with UoM conversion and pending."""
|
|
||||||
|
|
||||||
# demand 12 units, done 5 units
|
|
||||||
self._add_move(self.picking1, qty_demanded=12, qty_done=5, uom=self.uom_unit)
|
|
||||||
# demand 2 dozens (24 units), done 6 units
|
|
||||||
self._add_move(self.picking2, qty_demanded=2, qty_done=6, uom=self.uom_dozen)
|
|
||||||
|
|
||||||
self._recompute_batch()
|
|
||||||
|
|
||||||
self.assertEqual(len(self.batch.summary_line_ids), 1)
|
|
||||||
line = self.batch.summary_line_ids
|
|
||||||
self.assertEqual(line.product_id, self.product)
|
|
||||||
self.assertEqual(line.product_uom_id, self.uom_unit)
|
|
||||||
self.assertAlmostEqual(line.qty_demanded, 36.0)
|
|
||||||
self.assertAlmostEqual(line.qty_done, 11.0)
|
|
||||||
self.assertAlmostEqual(line.qty_pending, 25.0)
|
|
||||||
|
|
||||||
def test_collected_flag_preserved_on_recompute(self):
|
|
||||||
"""Collected stays checked after totals change."""
|
|
||||||
|
|
||||||
self._add_move(self.picking1, qty_demanded=1, qty_done=1, uom=self.uom_unit)
|
|
||||||
self._recompute_batch()
|
|
||||||
|
|
||||||
line = self.batch.summary_line_ids
|
|
||||||
line.is_collected = True
|
|
||||||
|
|
||||||
# Add more demand/done to trigger an update
|
|
||||||
self._add_move(self.picking1, qty_demanded=3, qty_done=2, uom=self.uom_unit)
|
|
||||||
self._recompute_batch()
|
|
||||||
|
|
||||||
self.assertTrue(self.batch.summary_line_ids.is_collected)
|
|
||||||
|
|
||||||
def test_cancelled_moves_are_ignored(self):
|
|
||||||
"""Cancelled moves do not count in the summary and lines are removed."""
|
|
||||||
|
|
||||||
move1 = self._add_move(
|
|
||||||
self.picking1, qty_demanded=4, qty_done=2, uom=self.uom_unit
|
|
||||||
)
|
|
||||||
move2 = self._add_move(
|
|
||||||
self.picking2, qty_demanded=6, qty_done=3, uom=self.uom_unit
|
|
||||||
)
|
|
||||||
self._recompute_batch()
|
|
||||||
self.assertEqual(len(self.batch.summary_line_ids), 1)
|
|
||||||
|
|
||||||
move1.write({"state": "cancel"})
|
|
||||||
move2.write({"state": "cancel"})
|
|
||||||
self._recompute_batch()
|
|
||||||
|
|
||||||
self.assertFalse(self.batch.summary_line_ids)
|
|
||||||
|
|
||||||
def test_no_required_product_error_on_confirm(self):
|
|
||||||
"""Confirming batch with pickings must not create summary lines without product."""
|
|
||||||
|
|
||||||
batch = self._create_batch_with_pickings()
|
|
||||||
|
|
||||||
# Trigger compute via confirm flow
|
|
||||||
batch.action_confirm()
|
|
||||||
|
|
||||||
self.assertTrue(batch.summary_line_ids)
|
|
||||||
self.assertFalse(
|
|
||||||
batch.summary_line_ids.filtered(lambda line: not line.product_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_batch_add_pickings_then_confirm(self):
|
|
||||||
"""Create empty draft batch, add multiple pickings, then confirm."""
|
|
||||||
|
|
||||||
# 1. Create empty batch in draft state
|
|
||||||
batch = self.env["stock.picking.batch"].create(
|
|
||||||
{"name": "Batch Empty Start", "picking_type_id": self.picking_type.id}
|
|
||||||
)
|
|
||||||
self.assertEqual(batch.state, "draft")
|
|
||||||
self.assertFalse(batch.picking_ids)
|
|
||||||
self.assertFalse(batch.summary_line_ids)
|
|
||||||
|
|
||||||
# 2. Create pickings with moves (without batch assignment)
|
|
||||||
product2 = self.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Product 2",
|
|
||||||
"uom_id": self.uom_unit.id,
|
|
||||||
"uom_po_id": self.uom_unit.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
picking_a = self.env["stock.picking"].create(
|
|
||||||
{
|
|
||||||
"picking_type_id": self.picking_type.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.env["stock.move"].create(
|
|
||||||
{
|
|
||||||
"name": self.product.name,
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_qty": 5.0,
|
|
||||||
"product_uom": self.uom_unit.id,
|
|
||||||
"picking_id": picking_a.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
picking_b = self.env["stock.picking"].create(
|
|
||||||
{
|
|
||||||
"picking_type_id": self.picking_type.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.env["stock.move"].create(
|
|
||||||
{
|
|
||||||
"name": product2.name,
|
|
||||||
"product_id": product2.id,
|
|
||||||
"product_uom_qty": 10.0,
|
|
||||||
"product_uom": self.uom_unit.id,
|
|
||||||
"picking_id": picking_b.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
picking_c = self.env["stock.picking"].create(
|
|
||||||
{
|
|
||||||
"picking_type_id": self.picking_type.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.env["stock.move"].create(
|
|
||||||
{
|
|
||||||
"name": self.product.name,
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_qty": 3.0,
|
|
||||||
"product_uom": self.uom_unit.id,
|
|
||||||
"picking_id": picking_c.id,
|
|
||||||
"location_id": self.location_src.id,
|
|
||||||
"location_dest_id": self.location_dest.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Add pickings to the batch
|
|
||||||
picking_a.batch_id = batch
|
|
||||||
picking_b.batch_id = batch
|
|
||||||
picking_c.batch_id = batch
|
|
||||||
|
|
||||||
self.assertEqual(len(batch.picking_ids), 3)
|
|
||||||
|
|
||||||
# 4. Confirm the batch — this should not raise product_id required error
|
|
||||||
batch.action_confirm()
|
|
||||||
|
|
||||||
self.assertEqual(batch.state, "in_progress")
|
|
||||||
|
|
||||||
# 5. Verify summary lines are correct
|
|
||||||
self.assertTrue(batch.summary_line_ids)
|
|
||||||
self.assertFalse(
|
|
||||||
batch.summary_line_ids.filtered(lambda line: not line.product_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Two products expected
|
|
||||||
products_in_summary = batch.summary_line_ids.mapped("product_id")
|
|
||||||
self.assertIn(self.product, products_in_summary)
|
|
||||||
self.assertIn(product2, products_in_summary)
|
|
||||||
|
|
||||||
# Check aggregated quantities
|
|
||||||
line_product1 = batch.summary_line_ids.filtered(
|
|
||||||
lambda line: line.product_id == self.product
|
|
||||||
)
|
|
||||||
self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
|
|
||||||
|
|
||||||
line_product2 = batch.summary_line_ids.filtered(
|
|
||||||
lambda line: line.product_id == product2
|
|
||||||
)
|
|
||||||
self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
|
|
||||||
|
|
@ -11,9 +11,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<record id="view_stock_picking_batch_summary_line_tree" model="ir.ui.view">
|
|
||||||
<field name="name">stock.picking.batch.summary.line.tree</field>
|
|
||||||
<field name="model">stock.picking.batch.summary.line</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list create="0" delete="0" default_order="product_categ_id,product_id">
|
|
||||||
<field name="product_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" nolabel="1"/>
|
|
||||||
</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>
|
|
||||||
|
|
@ -694,70 +694,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared handler for add-to-cart to reuse across grid/document listeners
|
|
||||||
var handleAddToCart = function (e) {
|
|
||||||
var cartBtn = e.target.closest(".add-to-cart-btn");
|
|
||||||
if (!cartBtn) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
var form = cartBtn.closest(".add-to-cart-form");
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
var productId = form.getAttribute("data-product-id");
|
|
||||||
var productName = form.getAttribute("data-product-name") || "Product";
|
|
||||||
var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0;
|
|
||||||
var quantityInput = form.querySelector(".product-qty");
|
|
||||||
var quantity = quantityInput ? parseFloat(quantityInput.value) : 1;
|
|
||||||
|
|
||||||
// Block add-to-cart if product is flagged out of stock (from template)
|
|
||||||
var isOutOfStock =
|
|
||||||
(form.getAttribute("data-out-of-stock") || "false") === "true" ||
|
|
||||||
(cartBtn.getAttribute("data-out-of-stock") || "false") === "true";
|
|
||||||
|
|
||||||
// Fallback guards in case cached markup drops the data attribute
|
|
||||||
if (!isOutOfStock) {
|
|
||||||
var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase();
|
|
||||||
var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase();
|
|
||||||
var iconEl = cartBtn.querySelector("i");
|
|
||||||
var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban");
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasBanIcon ||
|
|
||||||
btnTitle.includes("out of stock") ||
|
|
||||||
btnTitle.includes("sin stock") ||
|
|
||||||
btnAria.includes("out of stock") ||
|
|
||||||
btnAria.includes("sin stock")
|
|
||||||
) {
|
|
||||||
isOutOfStock = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isOutOfStock) {
|
|
||||||
var labels = self._getLabels();
|
|
||||||
self._showNotification(
|
|
||||||
labels.out_of_stock || "Product is out of stock",
|
|
||||||
"warning"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Adding:", {
|
|
||||||
productId: productId,
|
|
||||||
productName: productName,
|
|
||||||
productPrice: productPrice,
|
|
||||||
quantity: quantity,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (quantity > 0) {
|
|
||||||
self._addToCart(productId, productName, productPrice, quantity);
|
|
||||||
} else {
|
|
||||||
var labels2 = self._getLabels();
|
|
||||||
self._showNotification(
|
|
||||||
labels2.invalid_quantity || "Please enter a valid quantity",
|
|
||||||
"warning"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// First, adjust quantity steps for all existing inputs
|
// First, adjust quantity steps for all existing inputs
|
||||||
var unitInputs = document.querySelectorAll(".product-qty");
|
var unitInputs = document.querySelectorAll(".product-qty");
|
||||||
console.log("=== ADJUSTING QUANTITY STEPS (from data-quantity-step) ===");
|
console.log("=== ADJUSTING QUANTITY STEPS (from data-quantity-step) ===");
|
||||||
|
|
@ -843,16 +779,67 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to cart button (via event delegation on grid)
|
// Add to cart button (via event delegation)
|
||||||
productsGrid.addEventListener("click", handleAddToCart);
|
productsGrid.addEventListener("click", function (e) {
|
||||||
|
var cartBtn = e.target.closest(".add-to-cart-btn");
|
||||||
|
if (!cartBtn) return;
|
||||||
|
|
||||||
// Also attach a document-level delegation as fallback for dynamically
|
e.preventDefault();
|
||||||
// inserted products (infinite scroll) in case the grid listener is lost
|
var form = cartBtn.closest(".add-to-cart-form");
|
||||||
// after DOM replacement.
|
var productId = form.getAttribute("data-product-id");
|
||||||
if (!this._docCartListenerAttached) {
|
var productName = form.getAttribute("data-product-name") || "Product";
|
||||||
document.addEventListener("click", handleAddToCart, true);
|
var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0;
|
||||||
this._docCartListenerAttached = true;
|
var quantityInput = form.querySelector(".product-qty");
|
||||||
}
|
var quantity = quantityInput ? parseFloat(quantityInput.value) : 1;
|
||||||
|
|
||||||
|
// Block add-to-cart if product is flagged out of stock (from template)
|
||||||
|
var isOutOfStock =
|
||||||
|
(form.getAttribute("data-out-of-stock") || "false") === "true" ||
|
||||||
|
(cartBtn.getAttribute("data-out-of-stock") || "false") === "true";
|
||||||
|
|
||||||
|
// Fallback guards in case cached markup drops the data attribute
|
||||||
|
if (!isOutOfStock) {
|
||||||
|
var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase();
|
||||||
|
var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase();
|
||||||
|
var iconEl = cartBtn.querySelector("i");
|
||||||
|
var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban");
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasBanIcon ||
|
||||||
|
btnTitle.includes("out of stock") ||
|
||||||
|
btnTitle.includes("sin stock") ||
|
||||||
|
btnAria.includes("out of stock") ||
|
||||||
|
btnAria.includes("sin stock")
|
||||||
|
) {
|
||||||
|
isOutOfStock = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOutOfStock) {
|
||||||
|
var labels = self._getLabels();
|
||||||
|
self._showNotification(
|
||||||
|
labels.out_of_stock || "Product is out of stock",
|
||||||
|
"warning"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Adding:", {
|
||||||
|
productId: productId,
|
||||||
|
productName: productName,
|
||||||
|
productPrice: productPrice,
|
||||||
|
quantity: quantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (quantity > 0) {
|
||||||
|
self._addToCart(productId, productName, productPrice, quantity);
|
||||||
|
} else {
|
||||||
|
var labels = self._getLabels();
|
||||||
|
self._showNotification(
|
||||||
|
labels.invalid_quantity || "Please enter a valid quantity",
|
||||||
|
"warning"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_addToCart: function (productId, productName, productPrice, quantity) {
|
_addToCart: function (productId, productName, productPrice, quantity) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue