[FIX] stock_picking_batch_custom: prevent product_id null error on summary lines

- Use regular dict instead of defaultdict to avoid empty entries
- Make summary_line_ids readonly=True to prevent UI from inserting empty lines
- Add SQL constraint CHECK(product_id IS NOT NULL) as safeguard
- Use boolean_toggle widget for is_collected field
- Fix tests to use TransactionCase and invalidate_recordset
- Add test for empty batch + add pickings + confirm flow
This commit is contained in:
snt 2026-03-05 21:47:18 +01:00
parent ad8b759643
commit 3eeca66551
5 changed files with 304 additions and 45 deletions

View file

@ -1,8 +1,6 @@
# 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
@ -17,7 +15,7 @@ class StockPickingBatch(models.Model):
string="Product Summary",
compute="_compute_summary_line_ids",
store=True,
readonly=False,
readonly=True,
)
@api.depends(
@ -38,36 +36,57 @@ class StockPickingBatch(models.Model):
"""
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,
}
)
# 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"
)
done = move.product_uom._compute_quantity(
move.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_demanded"] += demand
entry = aggregates[product.id]
entry["product_id"] = product.id
entry["product_uom_id"] = product_uom.id
entry["qty_demanded"] += demand
entry["qty_done"] += done
# 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()
@ -76,9 +95,12 @@ class StockPickingBatch(models.Model):
}
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)
values["is_collected"] = previous_collected.get(product_id, False)
is_collected = previous_collected.get(product_id, False)
if existing_line:
commands.append(
fields.Command.update(
@ -87,12 +109,24 @@ class StockPickingBatch(models.Model):
"product_uom_id": values["product_uom_id"],
"qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"],
"is_collected": values["is_collected"],
"is_collected": is_collected,
},
)
)
else:
commands.append(fields.Command.create(values))
# 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)
@ -100,7 +134,10 @@ class StockPickingBatch(models.Model):
if line.product_id.id not in products_in_totals
]
batch.summary_line_ids = commands + obsolete_lines
if commands or obsolete_lines:
batch.summary_line_ids = commands + obsolete_lines
else:
batch.summary_line_ids = [fields.Command.clear()]
class StockPickingBatchSummaryLine(models.Model):
@ -108,16 +145,22 @@ class StockPickingBatchSummaryLine(models.Model):
_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",
string="Batch",
ondelete="cascade",
required=True,
index=True,
)
product_id = fields.Many2one(
comodel_name="product.product",
string="Product",
required=True,
index=True,
)