- Convertir 4 tests de decorador @patch a context manager 'with patch(...)' para evitar RuntimeError en LocalProxy de Werkzeug - Corregir patrón env(user=..., context=dict(...)) en Odoo 18 (sin .with_context()) - Agregar website real al mock para integración con helpers de pricing (_get_pricing_info) - Añadir pickup_date en fixture de existing_order para que _find_recent_draft_order localice correctamente - BUGFIX: Agregar (5,) a order_line para limpiar líneas previas al actualizar pedido existente Resultado: 0 failed, 0 errors de 4 tests en Docker para TestConfirmEskaera_Integration BREAKING: _create_or_update_sale_order ahora limpia las líneas anteriores con (5,) antes de asignar las nuevas cuando se actualiza un pedido existente. Comportamiento previo (duplicación de líneas) era un bug.
395 lines
14 KiB
Python
395 lines
14 KiB
Python
# Copyright 2026 Criptomart
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
|
|
from unittest.mock import patch
|
|
|
|
from odoo.exceptions import UserError
|
|
from odoo.tests import TransactionCase
|
|
from odoo.tests import tagged
|
|
|
|
|
|
@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.product_2 = cls.env["product.product"].create(
|
|
{
|
|
"name": "Test Product 2",
|
|
"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
|
|
|
|
def _create_partial_batch(self, qty_demanded=2.0, qty_done=1.0, extra_move=False):
|
|
batch = self.env["stock.picking.batch"].create(
|
|
{"name": "Batch Partial", "picking_type_id": self.picking_type.id}
|
|
)
|
|
batch.picking_type_id.create_backorder = "ask"
|
|
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,
|
|
"batch_id": batch.id,
|
|
}
|
|
)
|
|
|
|
move = self.env["stock.move"].create(
|
|
{
|
|
"name": self.product.name,
|
|
"product_id": self.product.id,
|
|
"product_uom_qty": qty_demanded,
|
|
"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": qty_done,
|
|
}
|
|
)
|
|
|
|
if extra_move:
|
|
self.env["stock.move"].create(
|
|
{
|
|
"name": self.product_2.name,
|
|
"product_id": self.product_2.id,
|
|
"product_uom_qty": 1.0,
|
|
"product_uom": self.uom_unit.id,
|
|
"picking_id": picking.id,
|
|
"location_id": self.location_src.id,
|
|
"location_dest_id": self.location_dest.id,
|
|
}
|
|
)
|
|
|
|
batch.action_confirm()
|
|
batch._compute_summary_line_ids()
|
|
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)
|
|
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": self.product_2.name,
|
|
"product_id": self.product_2.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(self.product_2, 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 == self.product_2
|
|
)
|
|
self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
|
|
|
|
def test_done_requires_all_summary_lines_collected_without_backorder(self):
|
|
"""Full validation still blocks if processed products are unchecked."""
|
|
|
|
batch = self._create_batch_with_pickings()
|
|
batch.action_confirm()
|
|
batch._compute_summary_line_ids()
|
|
|
|
self.assertTrue(batch.summary_line_ids)
|
|
self.assertFalse(batch.summary_line_ids.mapped("is_collected")[0])
|
|
|
|
with self.assertRaises(UserError):
|
|
batch.action_done()
|
|
|
|
def test_partial_validation_waits_for_backorder_wizard_before_blocking(self):
|
|
"""Partial batch validation must open the backorder wizard first."""
|
|
|
|
batch = self._create_partial_batch()
|
|
|
|
with patch.object(
|
|
type(self.env["stock.picking"]),
|
|
"_check_backorder",
|
|
autospec=True,
|
|
return_value=batch.picking_ids,
|
|
):
|
|
action = batch.action_done()
|
|
|
|
self.assertIsInstance(action, dict)
|
|
self.assertEqual(action.get("res_model"), "stock.backorder.confirmation")
|
|
|
|
def test_partial_validation_checks_only_processed_products(self):
|
|
"""Unchecked lines with no processed quantity must remain informative."""
|
|
|
|
batch = self._create_partial_batch(extra_move=True)
|
|
|
|
with self.assertRaisesRegex(UserError, self.product.display_name) as err:
|
|
batch._check_all_products_collected([self.product.id])
|
|
|
|
self.assertNotIn(self.product_2.display_name, str(err.exception))
|
|
|
|
def test_check_all_products_collected_passes_when_all_checked(self):
|
|
"""Collected validation helper must pass when all lines are checked."""
|
|
|
|
batch = self._create_batch_with_pickings()
|
|
batch.action_confirm()
|
|
batch._compute_summary_line_ids()
|
|
batch.summary_line_ids.write({"is_collected": True})
|
|
|
|
# Should not raise
|
|
batch._check_all_products_collected()
|