Files
tradon/modules/purchase_trade/tests/test_module.py
2026-06-15 20:04:37 +02:00

6810 lines
264 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
from decimal import Decimal
from unittest.mock import ANY, Mock, patch
from xml.etree import ElementTree
from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.exceptions import UserError
from trytond.transaction import Transaction
from trytond.modules.purchase_trade import valuation as valuation_module
from trytond.modules.purchase_trade import lot as lot_module
from trytond.modules.purchase_trade import purchase as purchase_module
from trytond.modules.purchase_trade import sale as sale_module
from trytond.modules.purchase_trade import stock as stock_module
from trytond.modules.purchase_trade import fee as fee_module
from trytond.modules.purchase_trade import company_defaults
from trytond.modules.purchase_trade.service import ContractFactory
class PurchaseTradeTestCase(ModuleTestCase):
'Test purchase_trade module'
module = 'purchase_trade'
def test_itsa_book_year_suffix_uses_april_fiscal_start(self):
'ITSA book year changes on April 1st'
self.assertEqual(
company_defaults._book_year_suffix(datetime.date(2026, 3, 31)),
'25')
self.assertEqual(
company_defaults._book_year_suffix(datetime.date(2026, 4, 1)),
'26')
@with_transaction()
def test_purchase_list_fields_use_first_trade_line(self):
'purchase list helper fields use the first real purchase line'
Purchase = Pool().get('purchase.purchase')
product = Mock(id=10)
period = Mock(id=20)
comment_line = Mock(type='comment')
trade_line = Mock(
type='line',
product=product,
price_type='basis',
del_period=period,
from_del=datetime.date(2026, 7, 1),
to_del=datetime.date(2026, 7, 31),
)
purchase = Purchase()
purchase.lines = [comment_line, trade_line]
self.assertEqual(purchase.get_first_line_product(None), 10)
self.assertEqual(purchase.get_first_line_price_type(None), 'basis')
self.assertEqual(purchase.get_first_line_del_period(None), 20)
self.assertEqual(
purchase.get_first_line_from_del(None),
datetime.date(2026, 7, 1))
self.assertEqual(
purchase.get_first_line_to_del(None),
datetime.date(2026, 7, 31))
@with_transaction()
def test_get_totals_without_rows(self):
'get_totals returns zeros when the query has no row'
Valuation = Pool().get('valuation.valuation')
cursor = Mock()
cursor.fetchone.return_value = None
connection = Mock()
connection.cursor.return_value = cursor
with patch(
'trytond.modules.purchase_trade.valuation.Transaction'
) as Transaction, patch.object(
Valuation, '__table__', return_value='valuation_valuation'):
Transaction.return_value.connection = connection
self.assertEqual(
Valuation.get_totals(), (Decimal(0), Decimal(0)))
@with_transaction()
def test_get_totals_without_previous_total(self):
'get_totals converts null variation to zero'
Valuation = Pool().get('valuation.valuation')
cursor = Mock()
cursor.fetchone.return_value = (Decimal('42.50'), None)
connection = Mock()
connection.cursor.return_value = cursor
with patch(
'trytond.modules.purchase_trade.valuation.Transaction'
) as Transaction, patch.object(
Valuation, '__table__', return_value='valuation_valuation'):
Transaction.return_value.connection = connection
self.assertEqual(
Valuation.get_totals(), (Decimal('42.50'), Decimal(0)))
def test_lotqt_plan_to_transport_splits_source_quantity(self):
'planning transport creates an isolated lot.qt split'
LotQt = lot_module.LotQt
source = Mock(
lot_p=Mock(id=1),
lot_s=Mock(id=2),
lot_quantity=Decimal('500'),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
)
source.has_shipment.return_value = False
source.is_planned_transport.return_value = False
values = {
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'truck',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'Early planning',
}
with patch.object(LotQt, 'save') as save, patch.object(
LotQt, 'create') as create:
LotQt.plan_to_transport(source, Decimal('125'), values)
self.assertEqual(source.lot_quantity, Decimal('375.00000'))
save.assert_called_once_with([source])
create.assert_called_once_with([{
'lot_p': 1,
'lot_s': 2,
'lot_quantity': Decimal('125.00000'),
'lot_unit': 3,
'lot_av': 'available',
'lot_status': 'forecast',
'lot_visible': True,
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'truck',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'Early planning',
}])
def test_lotqt_plan_to_transport_preserves_sale_only_sign(self):
'planning a sale-only lot.qt keeps the negative quantity direction'
LotQt = lot_module.LotQt
source = Mock(
lot_p=None,
lot_s=Mock(id=2),
lot_quantity=Decimal('-500'),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
)
source.has_shipment.return_value = False
source.is_planned_transport.return_value = False
with patch.object(LotQt, 'save'), patch.object(
LotQt, 'create') as create:
LotQt.plan_to_transport(
source, Decimal('125'),
{'planned_transport_type': 'vessel'})
self.assertEqual(source.lot_quantity, Decimal('-375.00000'))
self.assertEqual(
create.call_args[0][0][0]['lot_quantity'],
Decimal('-125.00000'))
self.assertEqual(create.call_args[0][0][0]['lot_p'], None)
self.assertEqual(create.call_args[0][0][0]['lot_s'], 2)
def test_lotqt_cancel_transport_plan_merges_matching_open_line(self):
'cancelling a transport plan returns quantity to the open lot.qt'
LotQt = lot_module.LotQt
planned = Mock(
lot_p=Mock(id=1),
lot_s=None,
lot_quantity=Decimal('125'),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
)
planned.has_shipment.return_value = False
planned.is_planned_transport.return_value = True
target = Mock(lot_quantity=Decimal('375'))
with patch.object(
LotQt, 'search', return_value=[target]) as search, patch.object(
LotQt, 'save') as save, patch.object(
LotQt, 'create') as create, patch.object(
LotQt, 'delete') as delete:
LotQt.cancel_transport_plan(planned)
search.assert_called_once()
self.assertEqual(target.lot_quantity, Decimal('500'))
save.assert_called_once_with([target])
create.assert_not_called()
delete.assert_called_once_with([planned])
def test_plan_transport_collects_affected_lines_before_cancel(self):
'plan transport wizard can keep affected lines before deleting lot.qt'
wizard = lot_module.LotPlanTransport()
purchase_line = Mock()
sale_line = Mock()
lotqt = Mock(
lot_p=Mock(line=purchase_line),
lot_s=Mock(sale_line=sale_line),
)
affected_lines = []
wizard._append_lotqt_affected_lines(lotqt, affected_lines)
self.assertEqual(affected_lines, [purchase_line, sale_line])
def test_plan_transport_defaults_multiple_lines(self):
'plan transport wizard opens one planning line per selected quantity'
wizard = lot_module.LotPlanTransport()
wizard.records = [
Mock(
id=10000001,
r_lot_quantity=Decimal('100'),
r_lot_matched=Decimal('0'),
r_lot_unit_line=Mock(id=3),
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20),
r_planned_transport_type='vessel',
r_planned_from_date=datetime.date(2026, 7, 1),
r_planned_to_date=datetime.date(2026, 7, 5),
r_planned_note='First leg'),
Mock(
id=10000002,
r_lot_quantity=Decimal('200'),
r_lot_matched=Decimal('0'),
r_lot_unit_line=Mock(id=4),
r_planned_from_location=None,
r_planned_to_location=None,
r_planned_transport_type=None,
r_planned_from_date=None,
r_planned_to_date=None,
r_planned_note=None),
]
values = wizard.default_plan(None)
self.assertEqual(len(values['lines']), 2)
self.assertEqual(values['lines'][0]['lotqt'], 1)
self.assertEqual(values['lines'][0]['quantity'], Decimal('100'))
self.assertEqual(values['lines'][0]['planned_from_location'], 10)
self.assertTrue(values['lines'][0]['cancel_plan'])
self.assertEqual(values['lines'][1]['lotqt'], 2)
self.assertEqual(values['lines'][1]['quantity'], Decimal('200'))
self.assertFalse(values['lines'][1]['cancel_plan'])
self.assertIsNone(values['default_lotqt'])
self.assertIsNone(values['default_unit'])
def test_plan_transport_defaults_single_line_for_new_rows(self):
'plan transport wizard gives new rows the current quantity line'
wizard = lot_module.LotPlanTransport()
wizard.records = [
Mock(
id=10002242,
r_lot_quantity=Decimal('1000'),
r_lot_matched=Decimal('0'),
r_lot_unit_line=Mock(id=3),
r_planned_from_location=None,
r_planned_to_location=None,
r_planned_transport_type=None,
r_planned_from_date=None,
r_planned_to_date=None,
r_planned_note=None),
]
values = wizard.default_plan(None)
self.assertEqual(values['default_lotqt'], 2242)
self.assertEqual(values['default_unit'], 3)
def test_plan_transport_line_defaults_from_context(self):
'new planning lines read their lot.qt and unit from the parent context'
context = {
'lot_plan_transport_default_lotqt': 2242,
'lot_plan_transport_default_unit': 3,
}
with patch.object(
lot_module, 'Transaction',
return_value=Mock(context=context)):
self.assertEqual(
lot_module.LotPlanTransportLine.default_lotqt(), 2242)
self.assertEqual(
lot_module.LotPlanTransportLine.default_unit(), 3)
def test_plan_transport_applies_each_planning_line(self):
'plan transport wizard applies each line independently'
wizard = lot_module.LotPlanTransport()
lotqt_a = Mock(
lot_p=Mock(line=Mock()),
lot_s=None)
lotqt_b = Mock(
lot_p=None,
lot_s=Mock(sale_line=Mock()))
wizard.plan = Mock(lines=[
Mock(
lotqt=lotqt_a,
cancel_plan=False,
quantity=Decimal('100'),
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
planned_transport_type='vessel',
planned_from_date=datetime.date(2026, 7, 1),
planned_to_date=datetime.date(2026, 7, 5),
planned_note='A'),
Mock(
lotqt=lotqt_b,
cancel_plan=False,
quantity=Decimal('200'),
planned_from_location=Mock(id=11),
planned_to_location=Mock(id=21),
planned_transport_type='truck',
planned_from_date=datetime.date(2026, 7, 6),
planned_to_date=datetime.date(2026, 7, 8),
planned_note='B'),
])
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_planning()
self.assertEqual(state, 'end')
LotQt.plan_to_transport.assert_any_call(lotqt_a, Decimal('100'), {
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'vessel',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'A',
})
LotQt.plan_to_transport.assert_any_call(lotqt_b, Decimal('200'), {
'planned_from_location': 11,
'planned_to_location': 21,
'planned_transport_type': 'truck',
'planned_from_date': datetime.date(2026, 7, 6),
'planned_to_date': datetime.date(2026, 7, 8),
'planned_note': 'B',
})
Lot.assert_lines_quantity_consistency.assert_called_once()
def test_lot_shipping_defaults_planned_locations(self):
'link to transport carries planned locations into shipment create'
wizard = lot_module.LotShipping()
wizard.records = [Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20),
)]
self.assertEqual(wizard.default_ship(None), {
'planned_from_location': 10,
'planned_to_location': 20,
})
def test_lot_shipping_defaults_uniform_planned_locations(self):
'link to transport carries uniform planned locations from selection'
wizard = lot_module.LotShipping()
wizard.records = [
Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20)),
Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20)),
]
self.assertEqual(wizard.default_ship(None), {
'planned_from_location': 10,
'planned_to_location': 20,
})
def test_lot_shipping_ignores_mixed_planned_locations(self):
'link to transport does not force mixed planned locations'
wizard = lot_module.LotShipping()
wizard.records = [
Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20)),
Mock(
r_planned_from_location=Mock(id=11),
r_planned_to_location=Mock(id=21)),
]
self.assertEqual(wizard.default_ship(None), {})
def test_lot_shipping_link_creates_shipment_from_dialog_supplier(self):
'link to transport creates shipment in from dialog supplier'
wizard = lot_module.LotShipping()
supplier = Mock(id=30)
created_shipment = Mock(id=40)
ShipmentIn = Mock()
ShipmentIn.create.return_value = [created_shipment]
wizard.records = []
wizard.ship = Mock(
shipment='in',
shipment_in=None,
shipment_out=None,
shipment_internal=None,
create_new_shipment=True,
shipment_supplier=supplier,
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
)
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
pool = Mock()
pool.get.side_effect = [Lot, Mock(), Mock(), ShipmentIn]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_shipping()
self.assertEqual(state, 'end')
ShipmentIn.create.assert_called_once_with([{
'supplier': 30,
'from_location': 10,
'to_location': 20,
}])
self.assertEqual(wizard.ship.shipment_in, created_shipment)
Lot.assert_lines_quantity_consistency.assert_called_once_with([])
def test_lot_shipping_rejects_existing_and_new_shipment(self):
'link to transport rejects choosing existing shipment and create new'
wizard = lot_module.LotShipping()
wizard.ship = Mock(
shipment='in',
shipment_in=Mock(id=40),
shipment_out=None,
shipment_internal=None,
create_new_shipment=True,
shipment_supplier=Mock(id=30),
)
with self.assertRaises(UserError):
wizard.transition_shipping()
def test_lot_shipping_physical_without_move_creates_shipment_move(self):
'linking a physical lot to shipment creates the missing stock move'
wizard = lot_module.LotShipping()
shipment = Mock(id=40)
wizard.ship = Mock(
shipment='in',
shipment_in=shipment,
shipment_out=None,
shipment_internal=None,
create_new_shipment=False,
quantity=None,
)
record = Mock(
id=2014,
r_lot_type='physic',
)
wizard.records = [record]
move = Mock()
move.get_linked_transit_move.return_value = None
lot = Mock(
id=2014,
move=None,
line=Mock(),
sale_line=None,
)
lot.get_current_quantity_converted.return_value = Decimal('22')
lot.getVlot_p.return_value = Mock()
lot.getVlot_s.return_value = None
lot.create_shipment_move.return_value = move
Lot = Mock()
Lot.return_value = lot
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
Move = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_shipping()
self.assertEqual(state, 'end')
self.assertEqual(lot.lot_shipment_in, shipment)
lot.create_shipment_move.assert_called_once_with(
'stock.shipment.in,40')
self.assertEqual(move.shipment, 'stock.shipment.in,40')
Move.save.assert_called_once_with([move])
lot.updateVirtualPart.assert_called_once_with(
Decimal('-22'), 'stock.shipment.in,40', None)
Lot.save.assert_called()
Lot.assert_lines_quantity_consistency.assert_called_once_with([
lot.line])
def test_lot_unship_restores_planned_open_quantity(self):
'unlinking a planned scheduled lot.qt returns it to planned'
wizard = lot_module.LotUnship()
record = Mock(
id=10000007,
r_lot_type='virtual',
)
wizard.records = [record]
purchase_line = Mock()
sale_line = Mock()
lot = Mock(line=purchase_line)
sale_lot = Mock(sale_line=sale_line)
lqt = Mock(
lot_quantity=Decimal('40'),
lot_p=lot,
lot_s=sale_lot,
lot_shipment_origin='stock.shipment.in,12',
)
lqt.is_planned_transport.return_value = True
Lot = Mock()
Lot.return_value = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
LotQt.return_value = lqt
Move = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_start()
self.assertEqual(state, 'end')
lot.updateVirtualPart.assert_called_once_with(
Decimal('-40'), 'stock.shipment.in,12', sale_lot, 'both')
LotQt.restore_planned_open_quantity.assert_called_once_with(
lqt, Decimal('40'))
lot.createVirtualPart.assert_not_called()
Lot.assert_lines_quantity_consistency.assert_called_once_with([
purchase_line, sale_line])
def test_add_physical_lots_requires_linked_transport(self):
'physical lots can only be added from a scheduled quantity'
LotQt = Pool().get('lot.qt')
lqt = Mock()
lqt.has_shipment.return_value = False
with self.assertRaises(UserError):
LotQt.add_physical_lots(lqt, [])
def test_add_physical_lot_copies_planned_transport_memory(self):
'physical lots keep planned transport fields from lot.qt'
LotQt = Pool().get('lot.qt')
lqt = LotQt()
planned_from = Mock()
planned_to = Mock()
purchase_line = Mock(product=Mock(), fees=[])
lqt.lot_p = Mock(line=purchase_line)
lqt.lot_s = None
lqt.lot_shipment_in = Mock(fees=[])
lqt.lot_shipment_internal = None
lqt.lot_shipment_out = None
lqt.planned_from_location = planned_from
lqt.planned_to_location = planned_to
lqt.planned_transport_type = 'truck'
lqt.planned_from_date = datetime.date(2026, 6, 15)
lqt.planned_to_date = datetime.date(2026, 6, 20)
lqt.planned_note = 'Gate 4'
physical_input = Mock(
lot_qt=Decimal('1'),
lot_unit=Mock(),
lot_unit_line=Mock(),
lot_quantity=Decimal('40'),
lot_gross_quantity=Decimal('41'),
lot_premium=Decimal('0'),
lot_chunk_key=None,
)
newlot = Mock()
newlot.line = purchase_line
newlot.sale_line = None
newlot.set_current_quantity = Mock()
newlot.get_current_quantity_converted.return_value = Decimal('40')
Lot = Mock()
Lot.return_value = newlot
FeeLots = Mock()
Purchase = Mock()
pool = Mock()
pool.get.side_effect = [Lot, FeeLots, Purchase]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
lot, _ = lqt.add_physical_lot(physical_input)
self.assertIs(lot.planned_from_location, planned_from)
self.assertIs(lot.planned_to_location, planned_to)
self.assertEqual(lot.planned_transport_type, 'truck')
self.assertEqual(lot.planned_from_date, datetime.date(2026, 6, 15))
self.assertEqual(lot.planned_to_date, datetime.date(2026, 6, 20))
self.assertEqual(lot.planned_note, 'Gate 4')
def test_remove_physical_lot_restores_planned_scheduled_quantity(self):
'removing a planned physical lot restores the planned scheduled lot.qt'
wizard = lot_module.LotRemove()
wizard.records = [Mock(id=2014)]
quantity = Decimal('40')
lot_s = Mock()
lot = Mock(
move=None,
lot_shipment_origin='stock.shipment.in,12',
lot_shipment_in=None,
lot_shipment_internal=None,
lot_shipment_out=None,
)
lot.get_current_quantity_converted.return_value = quantity
lot.getVlot_s.return_value = lot_s
lot.sale_line = Mock()
lot.is_planned_transport.return_value = True
Lot = Mock()
Lot.return_value = lot
LotQt = Mock()
Move = Mock()
Warning = Mock()
Warning.check.return_value = False
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move, Warning]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_start()
self.assertEqual(state, 'end')
LotQt.restore_planned_scheduled_quantity.assert_called_once_with(
lot, quantity, lot_s, 'stock.shipment.in,12')
lot.updateVirtualPart.assert_not_called()
Lot.delete.assert_called_once_with([lot])
def test_lot_shipping_scheduled_lotqt_keeps_planned_locations(self):
'scheduled lot.qt keeps the planned from and to locations'
wizard = lot_module.LotShipping()
wizard.ship = Mock(
shipment='in',
shipment_in=Mock(id=40),
shipment_out=None,
shipment_internal=None,
)
lqt = Mock(
lot_p=Mock(id=1),
lot_s=Mock(id=2),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
planned_transport_type='vessel',
planned_from_date=datetime.date(2026, 7, 1),
planned_to_date=datetime.date(2026, 7, 5),
planned_note='POL to POD',
)
LotQt = Mock()
wizard._create_scheduled_lotqt(LotQt, lqt, Decimal('125'))
LotQt.create.assert_called_once_with([{
'lot_p': 1,
'lot_s': 2,
'lot_quantity': Decimal('125'),
'lot_unit': 3,
'lot_av': 'available',
'lot_status': 'forecast',
'lot_visible': True,
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'vessel',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'POL to POD',
'lot_shipment_in': 40,
}])
@with_transaction()
def test_get_mtm_applies_component_ratio_as_percentage(self):
'get_mtm treats component ratio as a percentage'
Strategy = Pool().get('mtm.strategy')
strategy = Strategy()
strategy.scenario = Mock(
valuation_date='2026-03-29',
use_last_price=True,
)
strategy.currency = Mock()
strategy.components = [Mock(
price_source_type='curve',
price_index=Mock(get_price=Mock(return_value=Decimal('100'))),
price_matrix=None,
ratio=Decimal('25'),
)]
line = Mock(unit=Mock())
self.assertEqual(
strategy.get_mtm(line, Decimal('10')),
Decimal('250.00'))
@with_transaction()
def test_get_mtm_uses_fixed_pricing_component(self):
'get_mtm values fixed components like a constant price curve'
Strategy = Pool().get('mtm.strategy')
strategy = Strategy()
strategy.scenario = Mock(
valuation_date='2026-03-29',
use_last_price=True,
)
strategy.currency = Mock()
strategy.components = [Mock(
price_source_type='fixed',
price_index=None,
price_matrix=None,
get_price=Mock(return_value=Decimal('75')),
ratio=Decimal('100'),
)]
line = Mock(unit=Mock())
self.assertEqual(
strategy.get_mtm(line, Decimal('3')),
Decimal('225.00'))
@with_transaction()
def test_signed_strategy_mtm_follows_valuation_direction(self):
'strategy MTM follows purchase/sale valuation sign'
Valuation = Pool().get('valuation.valuation')
strategy = Mock(get_mtm=Mock(return_value=Decimal('349167.53')))
line = Mock()
self.assertEqual(
Valuation._signed_strategy_mtm(
{'type': 'pur. priced', 'amount': Decimal('-190686.15'),
'quantity': Decimal('4237.47')},
strategy, line),
Decimal('-349167.53'))
self.assertEqual(
Valuation._signed_strategy_mtm(
{'type': 'sale priced', 'amount': Decimal('349167.53'),
'quantity': Decimal('4237.47')},
strategy, line),
Decimal('349167.53'))
self.assertEqual(
Valuation._signed_strategy_mtm(
{'type': 'pur. priced', 'amount': Decimal('0'),
'quantity': Decimal('0')},
strategy, line),
Decimal('-349167.53'))
@with_transaction()
def test_strategy_mtm_lines_are_separate_from_realized_price(self):
'strategy MTM creates separate curve lines and preserves realized line'
Valuation = Pool().get('valuation.valuation')
valuation_date = datetime.date(2026, 6, 5)
previous_date = datetime.date(2026, 6, 4)
unit = Mock()
currency = Mock()
line = Mock(unit=unit)
curve_a = Mock(id=10)
curve_a.get_price.side_effect = [Decimal('100'), Decimal('90')]
curve_b = Mock(id=20)
curve_b.get_price.side_effect = [Decimal('50'), Decimal('45')]
strategy = Mock(
scenario=Mock(
valuation_date=valuation_date,
use_last_price=False),
currency=currency,
components=[
Mock(
price_source_type='curve',
price_index=curve_a,
ratio=Decimal('60')),
Mock(
price_source_type='curve',
price_index=curve_b,
ratio=Decimal('40')),
])
line.mtm = [strategy]
values = {
'type': 'pur. priced',
'price': Decimal('10'),
'amount': Decimal('-100'),
'base_amount': Decimal('-100'),
'quantity': Decimal('10'),
}
target = []
price_value = Mock()
price_value.search.return_value = [Mock(price_date=previous_date)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.return_value = price_value
Valuation._append_pnl_values(target, values, line)
self.assertEqual(len(target), 3)
self.assertEqual(target[0], values)
self.assertEqual(target[1]['type'], 'mtm')
self.assertEqual(target[1]['mtm_curve'], curve_a.id)
self.assertEqual(target[1]['price'], None)
self.assertEqual(target[1]['amount'], Decimal('0'))
self.assertEqual(target[1]['quantity'], Decimal('10'))
self.assertEqual(target[1]['mtm_price'], Decimal('100.0000'))
self.assertEqual(target[1]['mtm_price_prev'], Decimal('90.0000'))
self.assertEqual(target[1]['amount_prev'], Decimal('-540.00'))
self.assertEqual(target[1]['mtm'], Decimal('-600.00'))
self.assertEqual(target[2]['type'], 'mtm')
self.assertEqual(target[2]['mtm_curve'], curve_b.id)
self.assertEqual(target[2]['price'], None)
self.assertEqual(target[2]['amount'], Decimal('0'))
self.assertEqual(target[2]['quantity'], Decimal('10'))
self.assertEqual(target[2]['mtm_price'], Decimal('50.0000'))
self.assertEqual(target[2]['mtm_price_prev'], Decimal('45.0000'))
self.assertEqual(target[2]['amount_prev'], Decimal('-180.00'))
self.assertEqual(target[2]['mtm'], Decimal('-200.00'))
def test_purchase_pnl_uses_partial_lotqt_match_quantity(self):
'open matched purchase and sale pnl use the matched lot.qt quantity'
Valuation = valuation_module.Valuation
unit = Mock(id=1)
currency = Mock(id=2)
company = Mock(currency=2)
purchase = Mock(
id=10, currency=currency, company=company, party=Mock(id=11))
purchase_line = Mock(
id=20, price_type='priced', mtm=[], purchase=purchase,
product=Mock(id=21), unit=unit, lots=[])
purchase_lot = Mock(
id=30, lot_type='virtual', lot_price=Decimal('45'),
sale_line=None)
purchase_lot.get_current_quantity_converted.return_value = (
Decimal('1000'))
sale = Mock(
id=40, currency=currency, company=company, party=Mock(id=41))
sale_line = Mock(
id=50, price_type='priced', mtm=[], sale=sale,
product=Mock(id=51), unit=unit)
sale_lot = Mock(
id=60, lot_type='virtual', lot_price_sale=Decimal('100'),
sale_line=sale_line)
sale_lot.get_current_quantity_converted.return_value = Decimal('2000')
purchase_line.lots = [purchase_lot]
shipment = Mock(id=70)
lotqt = Mock(
lot_p=purchase_lot, lot_s=sale_lot,
lot_quantity=Decimal('500'), lot_unit=unit,
lot_shipment_in=shipment)
lotqt_model = Mock()
lotqt_model.search.return_value = [lotqt]
pool = Mock()
pool.get.return_value = lotqt_model
with patch.object(valuation_module, 'Pool', return_value=pool):
lines = Valuation.create_pnl_price_from_line(purchase_line)
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0]['type'], 'pur. priced')
self.assertEqual(lines[0]['quantity'], Decimal('500.00000'))
self.assertEqual(lines[0]['shipment_in'], shipment.id)
self.assertEqual(lines[0]['amount'], Decimal('-22500.00'))
self.assertEqual(lines[1]['type'], 'sale priced')
self.assertEqual(lines[1]['quantity'], Decimal('500.00000'))
self.assertEqual(lines[1]['shipment_in'], shipment.id)
self.assertEqual(lines[1]['amount'], Decimal('50000.00'))
@with_transaction()
def test_purchase_line_charter_conditions_inherit_header_when_empty(self):
'purchase line uses header charter conditions when it has no line terms'
Line = Pool().get('purchase.line')
line_condition = Mock(id=2)
header_condition = Mock(id=1)
line = Line()
line.charter_conditions = []
line.purchase = Mock(charter_conditions=[header_condition])
self.assertEqual(
line.get_effective_charter_conditions(), [header_condition])
line.charter_conditions = [line_condition]
self.assertEqual(
line.get_effective_charter_conditions(), [line_condition])
@with_transaction()
def test_sale_line_charter_conditions_inherit_header_when_empty(self):
'sale line uses header charter conditions when it has no line terms'
Line = Pool().get('sale.line')
line_condition = Mock(id=2)
header_condition = Mock(id=1)
line = Line()
line.charter_conditions = []
line.sale = Mock(charter_conditions=[header_condition])
self.assertEqual(
line.get_effective_charter_conditions(), [header_condition])
line.charter_conditions = [line_condition]
self.assertEqual(
line.get_effective_charter_conditions(), [line_condition])
@with_transaction()
def test_charter_condition_rate_type_sets_default_basis(self):
'charter condition rate copies the dynamic type default basis'
Rate = Pool().get('charter.condition.rate')
rate = Rate()
rate.rate_type = Mock(default_basis='per_day')
rate.on_change_rate_type()
self.assertEqual(rate.basis, 'per_day')
def test_charter_condition_rate_basis_includes_pumping_unit(self):
'charter condition rates expose a clear pumping throughput unit'
self.assertIn(('mt_per_hour', 'MT/hour'), stock_module.RATE_BASIS)
@with_transaction()
def test_charter_condition_role_defaults_from_context(self):
'charter condition role defaults to the business source context'
Condition = Pool().get('charter.condition')
with Transaction().set_context(charter_condition_role='sale'):
self.assertEqual(Condition.default_party_role(), 'customer')
condition = Condition()
self.assertEqual(
condition.get_party_roles(),
[(None, ''), ('customer', 'Customer')])
with Transaction().set_context(charter_condition_role='purchase'):
self.assertEqual(Condition.default_party_role(), 'supplier')
condition = Condition()
self.assertEqual(
condition.get_party_roles(),
[(None, ''), ('supplier', 'Supplier')])
with Transaction().set_context(charter_condition_role='charter_party'):
self.assertEqual(Condition.default_party_role(), 'owner')
condition = Condition()
roles = {role for role, _ in condition.get_party_roles()}
self.assertNotIn('supplier', roles)
self.assertNotIn('customer', roles)
@with_transaction()
def test_charter_condition_role_rejects_invalid_source_role(self):
'charter condition role is constrained by its source'
Condition = Pool().get('charter.condition')
condition = Condition()
condition.name = 'Invalid supplier condition on sale'
condition.party_role = 'supplier'
with Transaction().set_context(charter_condition_role='sale'):
with self.assertRaises(UserError):
Condition.validate([condition])
@with_transaction()
def test_sof_calculation_reads_applied_condition_rates(self):
'sof demurrage calculation uses the selected charter condition rates'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_allowed=None,
laytime_start_offset=None,
laytime_start_offset_unit=None,
turn_time=Decimal('6'),
turn_time_unit='hours',
rates=[
Mock(rate=Decimal('300'), rate_type=Mock(category='pumping')),
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
],
)
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof._applied_turn_delta(), datetime.timedelta(hours=6))
self.assertEqual(
sof._applied_laytime_allowed(Decimal('600')), 2.0)
self.assertEqual(
sof._applied_compensation_amount(), Decimal('-12000.00'))
self.assertEqual(sof.compensation_type, 'demurrage')
@with_transaction()
def test_sof_laytime_start_reads_structured_condition_rule(self):
'sof laytime start reads the structured event and offset'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_start_event='notice_of_readiness',
laytime_start_offset=Decimal('6'),
laytime_start_offset_unit='hours',
turn_time=None)
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
self.assertEqual(
sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 8, 0))
@with_transaction()
def test_sof_laytime_end_reads_structured_condition_rule(self):
'sof laytime end reads the structured end event'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(laytime_end_event='hoses_disconnected')
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
self.assertEqual(
sof._applied_laytime_end(),
datetime.datetime(2026, 6, 1, 13, 0))
@with_transaction()
def test_sof_laytime_ignores_free_text_clauses(self):
'sof laytime calculation ignores contractual free text fields'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_start_event='arrival',
laytime_start_offset=Decimal('1'),
laytime_start_offset_unit='hours',
laytime_end_event='end_pumping',
laytime_start='NOR + 6 hours',
laytime_end='Hoses disconnected',
turn_time=None)
sof.arrival_time = datetime.datetime(2026, 6, 1, 3, 0)
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
self.assertEqual(
sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 4, 0))
self.assertEqual(
sof._applied_laytime_end(),
datetime.datetime(2026, 6, 1, 12, 0))
@with_transaction()
def test_sof_calculation_info_explains_structured_inputs(self):
'sof calculation info displays the structured calculation inputs'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
rec_name='Discharge',
name='Discharge',
party_role='owner',
responsibility='ours',
laytime_allowed=Decimal('72'),
laytime_unit='hours',
laytime_start_event='notice_of_readiness',
laytime_start_offset=Decimal('6'),
laytime_start_offset_unit='hours',
laytime_end_event='hoses_disconnected',
laytime_start='Arrival + 1 day',
laytime_end='End pumping',
rates=[
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
Mock(rate=Decimal('12000'), rate_type=Mock(category='despatch')),
])
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 4, 10, 0)
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof.get_calculation_info('calculation_condition'), 'Discharge')
self.assertEqual(
sof.get_calculation_info('calculation_start_event'),
'Notice of Readiness')
self.assertEqual(
sof.get_calculation_info('calculation_start_event_time'),
'01/06/2026 02:00')
self.assertEqual(
sof.get_calculation_info('calculation_start_offset'), '6 hours')
self.assertEqual(
sof.get_calculation_info('calculation_end_event'),
'Hoses Disconnected')
self.assertEqual(
sof.get_calculation_info('calculation_end_event_time'),
'04/06/2026 10:00')
self.assertEqual(
sof.get_calculation_info('calculation_rate_used'),
'Demurrage: 24000 / day')
@with_transaction()
def test_sof_header_date_time_values_sync_to_datetime(self):
'sof header date and time fields build the technical datetime value'
Sof = Pool().get('sof.statement')
values = Sof._sync_datetime_values({
'notice_of_readiness_date': datetime.date(2026, 7, 6),
'notice_of_readiness_hour': datetime.time(8, 0),
})
self.assertEqual(
values['notice_of_readiness_time'],
datetime.datetime(2026, 7, 6, 8, 0))
@with_transaction()
def test_sof_event_date_time_values_sync_to_datetime(self):
'sof event date and time fields build the technical datetime values'
Event = Pool().get('sof.event')
values = Event._sync_datetime_values({
'start_date': datetime.date(2026, 1, 1),
'start_time': datetime.time(10, 0),
'end_date': datetime.date(2026, 1, 1),
'end_time': datetime.time(12, 0),
})
self.assertEqual(
values['start'], datetime.datetime(2026, 1, 1, 10, 0))
self.assertEqual(
values['end'], datetime.datetime(2026, 1, 1, 12, 0))
def test_contract_clause_variables_and_rendering_use_contract_context(self):
'contract clauses expose placeholders and render from purchase context'
clause = purchase_module.ContractClause()
clause.name = 'Payment'
clause.direction = 'both'
clause.text = (
'Buyer pays [party] under [incoterm] for [product] '
'contract [Contract_Number].')
self.assertEqual(
clause.get_variables('variables'),
'Contract_Number, incoterm, party, product')
selection = purchase_module.ContractClauseSelection()
selection.clause = clause
selection.purchase = Mock(
reference='PUR-001',
party=Mock(rec_name='Supplier Ltd'),
company=Mock(rec_name='Company SA'),
currency=Mock(rec_name='USD'),
incoterm=Mock(code='FOB'),
incoterm_location=None,
payment_term=None,
product_origin='',
lines=[
Mock(type='line', product=Mock(rec_name='Steel'), quantity=10,
unit=Mock(rec_name='MT')),
],
)
self.assertEqual(
selection.get_rendered_text('rendered_text'),
'Buyer pays Supplier Ltd under FOB for Steel contract PUR-001.')
@with_transaction()
def test_add_physical_lot_defaults_hidden_premium_and_chunk_key(self):
'add physical lot works when hidden tree fields are not loaded'
LotQt = Pool().get('lot.qt')
saved_lots = []
class FakeLot:
def __init__(self):
self.sale_line = None
self.id = 1
def set_current_quantity(self, quantity, gross_quantity, type_):
self.quantity = quantity
self.gross_quantity = gross_quantity
self.quantity_type = type_
def get_current_quantity_converted(self):
return self.quantity
@classmethod
def save(cls, lots):
saved_lots.extend(lots)
class AddLine:
lot_qt = Decimal('10')
lot_unit = Mock()
lot_unit_line = Mock()
lot_quantity = Decimal('10')
lot_gross_quantity = Decimal('11')
line = Mock(product=Mock(), fees=[])
line.purchase = Mock()
lqt = LotQt()
lqt.lot_p = Mock(line=line)
lqt.lot_s = None
lqt.lot_shipment_in = None
lqt.lot_shipment_internal = None
lqt.lot_shipment_out = None
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': FakeLot,
'fee.lots': Mock(),
'purchase.purchase': Mock(),
}[name]
lqt.add_physical_lot(AddLine())
self.assertEqual(len(saved_lots), 1)
self.assertIsNone(saved_lots[0].lot_chunk_key)
self.assertEqual(saved_lots[0].lot_premium, Decimal(0))
def test_get_strategy_mtm_price_applies_component_ratio(self):
'strategy mtm price applies component ratios'
strategy = Mock(
scenario=Mock(
valuation_date='2026-03-29',
use_last_price=True,
),
currency=Mock(),
)
strategy.components = [Mock(
price_source_type='curve',
price_index=Mock(get_price=Mock(return_value=Decimal('100'))),
price_matrix=None,
ratio=Decimal('25'),
)]
line = Mock(unit=Mock())
self.assertEqual(
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('25.0000'))
def test_get_strategy_mtm_price_keeps_signed_component_spread(self):
'strategy mtm price keeps signed spread between different components'
strategy = Mock(
scenario=Mock(
valuation_date='2026-03-29',
use_last_price=True,
),
currency=Mock(),
)
strategy.components = [
Mock(
price_source_type='curve',
price_index=Mock(get_price=Mock(return_value=Decimal('120'))),
price_matrix=None,
ratio=Decimal('100'),
),
Mock(
price_source_type='curve',
price_index=Mock(get_price=Mock(return_value=Decimal('80'))),
price_matrix=None,
ratio=Decimal('-100'),
),
]
line = Mock(unit=Mock())
self.assertEqual(
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('40.0000'))
def test_get_strategy_mtm_price_uses_fixed_pricing_component(self):
'strategy mtm price uses fixed components like constant price curves'
strategy = Mock(
scenario=Mock(
valuation_date='2026-03-29',
use_last_price=True,
),
currency=Mock(),
)
strategy.components = [Mock(
price_source_type='fixed',
price_index=None,
price_matrix=None,
get_price=Mock(return_value=Decimal('75')),
ratio=Decimal('50'),
)]
line = Mock(unit=Mock())
self.assertEqual(
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('37.5000'))
def test_sale_line_is_unmatched_checks_lot_links(self):
'sale line unmatched helper ignores empty matches and detects linked purchases'
sale_line = Mock()
sale_line.get_matched_lines.return_value = []
self.assertTrue(
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
linked = Mock(lot_p=Mock(line=Mock()))
sale_line.get_matched_lines.return_value = [linked]
self.assertFalse(
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
def test_parse_numbers_supports_inline_and_legacy_separators(self):
'parse_numbers keeps supporting inline entry and legacy separators'
self.assertEqual(
valuation_module.ValuationProcess._parse_numbers(
'PUR-001 PUR-002, PUR-003\nPUR-004;PUR-005'
),
['PUR-001', 'PUR-002', 'PUR-003', 'PUR-004', 'PUR-005'])
def test_get_generate_types_maps_business_groups(self):
'valuation type groups map to the expected stored valuation types'
Valuation = Pool().get('valuation.valuation')
self.assertEqual(
Valuation._get_generate_types('fees'),
{'line fee', 'pur. fee', 'sale fee', 'shipment fee'})
self.assertEqual(
Valuation._get_generate_types('derivatives'),
{'derivative'})
self.assertIn('pur. priced', Valuation._get_generate_types('goods'))
self.assertIn('mtm', Valuation._get_generate_types('goods'))
def test_filter_values_by_types_keeps_matching_entries_only(self):
'type filtering keeps only the requested valuation entries'
Valuation = Pool().get('valuation.valuation')
values = [
{'type': 'pur. fee', 'amount': Decimal('10')},
{'type': 'pur. priced', 'amount': Decimal('20')},
{'type': 'derivative', 'amount': Decimal('30')},
]
self.assertEqual(
Valuation._filter_values_by_types(
values, {'pur. fee', 'derivative'}),
[
{'type': 'pur. fee', 'amount': Decimal('10')},
{'type': 'derivative', 'amount': Decimal('30')},
])
def test_generate_keeps_finished_purchase_line_physical_pnl(self):
'finished purchase lines still generate physical lot valuation'
Valuation = Pool().get('valuation.valuation')
line = Mock(finished=True)
with patch.object(Valuation, '_delete_existing') as delete_existing, patch.object(
Valuation, 'create_pnl_fee_from_line',
return_value=[{'type': 'pur. fee'}]) as create_fees, patch.object(
Valuation, 'create_pnl_price_from_line',
return_value=[{'type': 'pur. priced'}]) as create_prices, patch.object(
Valuation, 'create_pnl_der_from_line',
return_value=[{'type': 'derivative'}]) as create_derivatives, patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
Valuation.generate(line)
delete_existing.assert_called_once_with(line, selected_types=None)
create_fees.assert_called_once_with(line)
create_prices.assert_called_once_with(line)
create_derivatives.assert_called_once_with(line)
valuation_model = PoolMock.return_value.get.return_value
valuation_model.create.assert_any_call([
{'type': 'pur. fee'},
{'type': 'pur. priced'},
{'type': 'derivative'},
])
def test_generate_keeps_finished_sale_line_physical_pnl(self):
'finished sale lines still generate physical lot valuation'
Valuation = Pool().get('valuation.valuation')
sale_line = Mock(finished=True)
with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object(
Valuation, 'create_pnl_fee_from_sale_line',
return_value=[{'type': 'sale fee'}]) as create_fees, patch.object(
Valuation, 'create_pnl_price_from_sale_line',
return_value=[{'type': 'sale priced'}]) as create_prices, patch.object(
Valuation, 'create_pnl_der_from_sale_line',
return_value=[{'type': 'derivative'}]) as create_derivatives, patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
Valuation.generate_from_sale_line(sale_line)
delete_existing.assert_called_once_with(sale_line, selected_types=None)
create_fees.assert_called_once_with(sale_line)
create_prices.assert_called_once_with(sale_line)
create_derivatives.assert_called_once_with(sale_line)
valuation_model = PoolMock.return_value.get.return_value
valuation_model.create.assert_any_call([
{'type': 'sale fee'},
{'type': 'sale priced'},
{'type': 'derivative'},
])
def test_update_daily_snapshot_generates_all_purchases_and_unmatched_sales(self):
'daily valuation cron snapshots purchases and unmatched sale lines'
Valuation = Pool().get('valuation.valuation')
PurchaseLine = Mock()
SaleLine = Mock()
purchase_lines = [Mock(id=1), Mock(id=2)]
unmatched_sale_line = Mock(id=3)
unmatched_sale_line.get_matched_lines.return_value = []
matched_sale_line = Mock(id=4)
matched_sale_line.get_matched_lines.return_value = [
Mock(lot_p=Mock(line=Mock(id=5)))]
PurchaseLine.search.return_value = purchase_lines
SaleLine.search.return_value = [unmatched_sale_line, matched_sale_line]
with patch(
'trytond.modules.purchase_trade.valuation.Pool'
) as PoolMock, patch.object(
Valuation, 'generate') as generate, patch.object(
Valuation, 'generate_from_sale_line'
) as generate_from_sale_line:
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.line': PurchaseLine,
'sale.line': SaleLine,
}[name]
Valuation.update_daily_snapshot()
generate.assert_any_call(purchase_lines[0])
generate.assert_any_call(purchase_lines[1])
self.assertEqual(generate.call_count, 2)
generate_from_sale_line.assert_called_once_with(unmatched_sale_line)
def test_update_daily_pricing_checks_all_purchase_and_sale_lines(self):
'daily pricing cron refreshes purchase and sale pricing lines'
Pricing = Pool().get('pricing.pricing')
PurchaseLine = Mock()
SaleLine = Mock()
purchase_lines = [Mock(id=1), Mock(id=2)]
sale_lines = [Mock(id=3), Mock(id=4)]
PurchaseLine.search.return_value = purchase_lines
SaleLine.search.return_value = sale_lines
with patch(
'trytond.modules.purchase_trade.pricing.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.line': PurchaseLine,
'sale.line': SaleLine,
}[name]
Pricing.update_daily_pricing()
PurchaseLine.search.assert_called_once_with([])
SaleLine.search.assert_called_once_with([])
for line in purchase_lines + sale_lines:
line.check_pricing.assert_called_once_with()
def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self):
'purchase fee valuation treats an uncomputed rate amount as zero'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Financing fees')
supplier = Mock(id=4)
lot = Mock(id=5, sale_line=None, lot_type='virtual')
lot.get_current_quantity_converted.return_value = Decimal('10')
fee = Mock(
id=11,
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='rate',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
fee.get_amount.return_value = None
line = Mock(
id=6,
lots=[lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=7, currency=currency),
unit=unit,
)
fee.line = line
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values[0]['amount'], Decimal('0'))
self.assertEqual(values[0]['fee'], fee.id)
def test_create_pnl_fee_from_line_ignores_orphan_fee_lot(self):
'purchase fee valuation ignores fees without business attachment'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
lot = Mock(id=5, sale_line=None, lot_type='virtual')
lot.get_current_quantity_converted.return_value = Decimal('10')
fee = Mock(
line=None,
sale_line=None,
shipment_in=None,
shipment_out=None,
shipment_internal=None,
)
line = Mock(
id=6,
lots=[lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=7, currency=currency),
unit=unit,
)
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values, [])
def test_fee_quantity_sync_uses_physical_lots_when_present(self):
'fee quantity sync ignores virtual lot once physical lots exist'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 1
fee.mode = 'perqt'
fee.quantity = Decimal('100')
fee.unit = Mock()
virtual = Mock(lot_type='virtual')
virtual.get_current_quantity_converted.return_value = Decimal('100')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('40')
fee_lots = Mock()
fee_lots.search.return_value = [
Mock(lot=virtual),
Mock(lot=physical),
]
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(Fee, 'save') as save:
PoolMock.return_value.get.return_value = fee_lots
fee.sync_quantity_from_lots()
self.assertEqual(fee.quantity, Decimal('40.00000'))
save.assert_called_once_with([fee])
def test_fee_ppack_auto_sync_keeps_auto_quantity(self):
'per packing auto fee keeps the on-change quantity on save'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 1
fee.mode = 'ppack'
fee.auto_calculation = True
fee.quantity = Decimal('3')
fee.unit = Mock()
lot = Mock(lot_type='physic', lot_qt=Decimal('990'))
fee_lots = Mock()
fee_lots.search.return_value = [Mock(lot=lot)]
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(Fee, 'save') as save:
PoolMock.return_value.get.return_value = fee_lots
fee.sync_quantity_from_lots()
self.assertEqual(fee.quantity, Decimal('3'))
save.assert_not_called()
def test_fee_linked_price_updates_fee_price(self):
'fee linked price converts to the accounting currency price'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.enable_linked_currency = True
fee.linked_price = Decimal('15')
fee.linked_currency = Mock(factor=Decimal('0.01'))
fee.linked_unit = Mock()
fee.unit = Mock()
Uom = Mock()
Uom.compute_qty.return_value = Decimal('2')
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = Uom
fee.on_change_linked_price()
self.assertEqual(fee.price, Decimal('0.3000'))
def test_fee_pnl_regeneration_uses_shipment_lotqt_purchase_line(self):
'fee pnl regeneration follows shipment lot.qt back to purchase line'
Fee = Pool().get('fee.fee')
purchase_line = Mock(id=1)
lot_p = Mock(line=purchase_line, sale_line=None)
shipment = Mock(
incoming_moves=[],
lotqt=[Mock(lot_p=lot_p, lot_s=None)],
)
fee = Mock(line=None, sale_line=None, lots=[], shipment_in=shipment)
valuation = Mock()
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = valuation
Fee._regenerate_fee_pnl(fees=[fee])
valuation.generate.assert_called_once_with(
purchase_line, valuation_type='fees')
valuation.generate_from_sale_line.assert_not_called()
def test_fee_pnl_regeneration_updates_purchase_and_sale_lot_lines(self):
'fee pnl regeneration follows physical fee lots to purchase and sale'
Fee = Pool().get('fee.fee')
purchase_line = Mock(id=1)
sale_line = Mock(id=2)
lot = Mock(line=purchase_line, sale_line=sale_line)
fee = Mock(line=None, sale_line=None, lots=[lot], shipment_in=None)
valuation = Mock()
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = valuation
Fee._regenerate_fee_pnl(fees=[fee])
valuation.generate.assert_called_once_with(
purchase_line, valuation_type='fees')
valuation.generate_from_sale_line.assert_called_once_with(
sale_line, valuation_type='fees')
def test_purchase_fee_pnl_ignores_virtual_fee_lot_when_physical_exists(self):
'purchase fee pnl uses physical fee lots instead of the residual virtual'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Handling')
supplier = Mock(id=4)
virtual = Mock(id=5, sale_line=None, lot_type='virtual')
virtual.get_current_quantity_converted.return_value = Decimal('60')
physical = Mock(id=6, sale_line=None, lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('40')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='perqt',
price=Decimal('2'),
currency=currency,
shipment_in=None,
sale_line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
fee.is_effective_fee_lot.side_effect = lambda lot: lot is physical
fee.get_price_per_qt.return_value = Decimal('2')
line = Mock(
id=7,
lots=[virtual, physical],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=8, currency=currency),
unit=unit,
)
fee.line = line
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(len(values), 1)
self.assertEqual(values[0]['lot'], physical.id)
self.assertEqual(values[0]['quantity'], Decimal('40.00000'))
self.assertEqual(values[0]['amount'], Decimal('-80.00'))
def test_purchase_rate_fee_amount_uses_virtual_lot_quantity(self):
'purchase rate fee amount uses the financing delta as absolute period'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'rate'
fee.price = Decimal('12')
fee.fee_date = None
fee.quantity = None
fee.unit = Mock()
fee.shipment_in = None
lot = Mock()
lot.get_current_quantity_converted.return_value = Decimal('10')
fee.line = Mock(
unit_price=Decimal('100'),
lots=[lot],
estimated_date=[
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23),
fin_int_delta=10,
),
],
)
fee.sale_line = None
self.assertEqual(fee.get_amount(), Decimal('3.33'))
def test_sale_rate_fee_amount_uses_absolute_financing_delta(self):
'sale rate fee amount uses the financing delta as absolute period'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'rate'
fee.price = Decimal('12')
fee.fee_date = None
fee.quantity = Decimal('10')
fee.unit = Mock()
fee.shipment_in = None
fee.line = None
fee.sale_line = Mock(
unit_price=Decimal('100'),
lots=[],
estimated_date=[
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23),
fin_int_delta=10,
),
],
)
self.assertEqual(fee.get_amount(), Decimal('3.33'))
def test_purchase_line_bldate_uses_shipment_bl_before_estimated_date(self):
'purchase line BL trigger uses the real shipment BL date first'
Line = Pool().get('purchase.line')
line = Line()
line.lots = [
Mock(
lot_shipment_in=Mock(
bl_date=datetime.date(2026, 6, 3))),
]
line.estimated_date = [
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23)),
]
self.assertEqual(
line.get_date('bldate'),
datetime.date(2026, 6, 3))
def test_sale_line_bldate_uses_shipment_bl_before_estimated_date(self):
'sale line BL trigger uses the real shipment BL date first'
Line = Pool().get('sale.line')
line = Line()
line.lots = [
Mock(
lot_shipment_in=Mock(
bl_date=datetime.date(2026, 6, 3))),
]
line.estimated_date = [
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23)),
]
self.assertEqual(
line.get_date('bldate'),
datetime.date(2026, 6, 3))
def test_sale_rate_fee_amount_stays_positive_with_negative_delta(self):
'sale rate fee display amount is unsigned even when financing delta is negative'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'rate'
fee.price = Decimal('12')
fee.fee_date = None
fee.quantity = Decimal('10')
fee.unit = Mock()
fee.shipment_in = None
fee.line = None
fee.sale_line = Mock(
unit_price=Decimal('100'),
lots=[],
estimated_date=[
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23),
fin_int_delta=-10,
),
],
)
self.assertEqual(fee.get_amount(), Decimal('3.33'))
def test_fee_pnl_applies_pay_sign_to_unsigned_rate_amount(self):
'fee pnl sign is applied once from PAY/REC'
Valuation = Pool().get('valuation.valuation')
lot = Mock()
lot.get_current_quantity_converted.return_value = Decimal('10')
fee = Mock(
mode='rate',
price=Decimal('12'),
unit=Mock(),
line=None,
sale_line=Mock(
unit_price=Decimal('100'),
estimated_date=[
Mock(trigger='bldate', fin_int_delta=-10),
],
),
)
self.assertEqual(
Valuation._fee_amount_for_lot_or_zero(fee, lot),
Decimal('3.33'))
def test_create_pnl_price_from_line_keeps_finished_physical_sale_line(self):
'purchase valuation keeps finished sale-side pnl on physical lots'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
company = Mock(currency=currency.id)
unit = Mock(id=1)
product = Mock(id=1)
party = Mock(id=1)
sale = Mock(id=2, currency=currency, company=company, party=party)
finished_sale_line = Mock(
finished=True,
price_type='priced',
mtm=[],
sale=sale,
unit=unit,
product=product,
)
sale_lot = Mock(
id=3,
sale_line=finished_sale_line,
lot_price_sale=Decimal('20'),
lot_type='physic',
)
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
purchase_lot = Mock(
id=4,
sale_line=None,
lot_price=Decimal('10'),
lot_type='physic',
)
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
line = Mock(
finished=False,
lots=[purchase_lot],
price_type='priced',
mtm=[],
purchase=Mock(id=1, currency=currency, company=company, party=party),
unit=unit,
product=product,
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
with patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.qt': lot_qt_model,
'currency.currency': Mock(),
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
}[name]
values = Valuation.create_pnl_price_from_line(line)
self.assertEqual(len(values), 2)
self.assertEqual(values[0]['type'], 'pur. priced')
self.assertEqual(values[0]['sale_line'], finished_sale_line.id)
self.assertEqual(values[1]['type'], 'sale priced')
self.assertEqual(values[1]['sale_line'], finished_sale_line.id)
self.assertEqual(values[1]['reference'], 'Sale/Physic')
def test_purchase_open_pnl_price_links_sale_from_lot_qt(self):
'purchase-side open price pnl is linked to the matched sale via lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
company = Mock(currency=currency.id)
unit = Mock(id=1)
product = Mock(id=1)
purchase_party = Mock(id=1)
sale_party = Mock(id=2)
sale = Mock(id=3, currency=currency, company=company, party=sale_party)
sale_line = Mock(
id=4,
finished=False,
price_type='priced',
mtm=[],
sale=sale,
unit=unit,
product=product,
)
sale_lot = Mock(
id=5,
sale_line=sale_line,
lot_price_sale=Decimal('20'),
lot_type='virtual',
)
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
purchase_lot = Mock(
id=6,
sale_line=None,
lot_price=Decimal('10'),
lot_type='virtual',
)
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
line = Mock(
finished=False,
lots=[purchase_lot],
price_type='priced',
mtm=[],
purchase=Mock(
id=7, currency=currency, company=company, party=purchase_party),
unit=unit,
product=product,
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('2'),
lot_unit=unit,
lot_shipment_in=None)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.qt': lot_qt_model,
'currency.currency': Mock(),
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
}[name]
values = Valuation.create_pnl_price_from_line(line)
purchase_values = [v for v in values if v['type'] == 'pur. priced']
self.assertEqual(purchase_values[0]['sale'], sale.id)
self.assertEqual(purchase_values[0]['sale_line'], sale_line.id)
def test_purchase_open_pnl_fee_links_sale_from_lot_qt(self):
'purchase-side open fee pnl is linked to the matched sale via lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Financing interests')
supplier = Mock(id=4)
sale = Mock(id=5)
sale_line = Mock(id=6, sale=sale, finished=False)
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='lumpsum',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
line = Mock(
id=9,
finished=False,
lots=[purchase_lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=10, currency=currency),
unit=unit,
)
fee.line = line
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('2'),
lot_unit=unit,
lot_shipment_in=None)]
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': lot_qt_model,
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values[0]['type'], 'pur. fee')
self.assertEqual(values[0]['sale'], sale.id)
self.assertEqual(values[0]['sale_line'], sale_line.id)
def test_purchase_pnl_fee_deduplicates_sale_lot_seen_twice(self):
'purchase-side fee pnl does not duplicate a sale lot already in matching'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Broker commission')
supplier = Mock(id=4)
sale = Mock(id=5)
sale_line = Mock(id=6, sale=sale, finished=False)
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='lumpsum',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=sale_line,
unit=unit,
)
line = Mock(
id=9,
finished=False,
lots=[sale_lot],
get_matched_lines=Mock(return_value=[Mock(lot_s=sale_lot)]),
purchase=Mock(id=10, currency=currency),
unit=unit,
)
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 5, 14))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(len(values), 1)
self.assertEqual(values[0]['type'], 'sale fee')
self.assertEqual(values[0]['lot'], sale_lot.id)
def test_sale_open_fee_pnl_splits_by_shipment_lotqt(self):
'sale open fee pnl is split between shipped and unshipped lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
shipment = Mock(id=70)
sale = Mock(id=3, currency=currency)
sale_line = Mock(id=4, sale=sale, unit=unit, finished=False)
sale_lot = Mock(id=5, sale_line=sale_line, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('220')
sale_line.lots = [sale_lot]
fee = Mock(
id=12,
product=Mock(id=6, name='Broker commission'),
supplier=Mock(id=7),
type='budgeted',
p_r='pay',
mode='perqt',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=sale_line,
line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
fee.is_effective_fee_lot.return_value = True
fee.get_price_per_qt.return_value = Decimal('10')
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('88'),
lot_unit=unit,
lot_shipment_in=shipment),
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('132'),
lot_unit=unit,
lot_shipment_in=None),
]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 5, 14))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': lot_qt_model,
}[name]
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
self.assertEqual(len(values), 2)
self.assertEqual(values[0]['fee'], fee.id)
self.assertEqual(values[1]['fee'], fee.id)
self.assertEqual(values[0]['shipment_in'], shipment.id)
self.assertEqual(values[0]['quantity'], Decimal('88.00000'))
self.assertEqual(values[0]['amount'], Decimal('-880.00'))
self.assertIsNone(values[1]['shipment_in'])
self.assertEqual(values[1]['quantity'], Decimal('132.00000'))
self.assertEqual(values[1]['amount'], Decimal('-1320.00'))
def test_snapshot_identity_prefers_sale_line_over_purchase_line(self):
'valuation snapshot identity ignores purchase line when sale line exists'
Valuation = Pool().get('valuation.valuation')
base = {
'purchase': 1,
'line': 2,
'sale': 3,
'sale_line': 4,
'lot': 5,
'type': 'sale fee',
'reference': 'Broker/Open',
'counterparty': 6,
'product': 7,
'state': 'budgeted',
'strategy': None,
'price': Decimal('10'),
'quantity': Decimal('2'),
'amount': Decimal('-20'),
}
regenerated = base.copy()
regenerated.update({
'purchase': None,
'line': None,
'price': Decimal('12'),
'quantity': Decimal('3'),
'amount': Decimal('-36'),
})
self.assertEqual(
Valuation._value_key(base),
Valuation._value_key(regenerated))
def test_snapshot_identity_keeps_mtm_curves_distinct(self):
'valuation snapshot identity keeps strategy curves distinct'
Valuation = Pool().get('valuation.valuation')
base = {
'purchase': 1,
'line': 2,
'sale': None,
'sale_line': None,
'lot': 5,
'type': 'pur. priced',
'reference': 'Open',
'counterparty': 6,
'product': 7,
'state': 'fixed',
'strategy': 8,
'shipment_in': None,
'mtm_curve': 10,
}
other_curve = base.copy()
other_curve['mtm_curve'] = 20
self.assertNotEqual(
Valuation._value_key(base),
Valuation._value_key(other_curve))
self.assertIn(
('mtm_curve', '=', 10),
Valuation._snapshot_identity_domain(base))
def test_generate_from_matched_sale_line_does_not_create_snapshot(self):
'matched sale line pnl is owned by the matched purchase line'
Valuation = Pool().get('valuation.valuation')
purchase_line = Mock(id=10)
sale_line = Mock()
sale_line.get_matched_lines.return_value = [
Mock(lot_p=Mock(line=purchase_line))]
with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object(
Valuation, 'create_pnl_fee_from_sale_line') as create_fees, patch.object(
Valuation, 'generate') as generate:
Valuation.generate_from_sale_line(sale_line, valuation_type='fees')
generate.assert_called_once_with(purchase_line, valuation_type='fees')
delete_existing.assert_not_called()
create_fees.assert_not_called()
def test_purchase_open_pnl_fee_skips_zero_virtual_lot(self):
'purchase-side fee pnl ignores open virtual lots with no quantity'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
purchase_lot.get_current_quantity_converted.return_value = Decimal('0')
line = Mock(
id=9,
finished=False,
lots=[purchase_lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=10, currency=currency),
unit=unit,
)
fee_lots = Mock()
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values, [])
fee_lots.search.assert_not_called()
def test_sale_open_pnl_fee_skips_zero_virtual_lot(self):
'sale-side fee pnl ignores open virtual lots with no quantity'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
sale_lot = Mock(id=7, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('0')
sale_line = Mock(
id=6,
finished=False,
lots=[sale_lot],
sale=Mock(id=5, currency=currency),
unit=unit,
)
fee_lots = Mock()
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
self.assertEqual(values, [])
fee_lots.search.assert_not_called()
def test_sale_report_crop_name_handles_missing_crop(self):
'sale report crop name returns an empty string when crop is missing'
Sale = Pool().get('sale.sale')
sale = Sale()
sale.crop = None
self.assertEqual(sale.report_crop_name, '')
sale.crop = Mock(name='crop')
sale.crop.name = 'Main Crop'
self.assertEqual(sale.report_crop_name, 'Main Crop')
def test_sale_line_default_pricing_rule_comes_from_configuration(self):
'sale line pricing_rule defaults to the purchase_trade singleton value'
SaleLine = Pool().get('sale.line')
config = Mock(pricing_rule='Default pricing rule')
configuration_model = Mock()
configuration_model.search.return_value = [config]
with patch(
'trytond.modules.purchase_trade.sale.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = configuration_model
self.assertEqual(
SaleLine.default_pricing_rule(), 'Default pricing rule')
def test_sale_line_compute_unit_price_keeps_existing_value_when_base_returns_none(self):
'sale line quantity changes keep the manual unit price when no sale price is found'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.unit_price = Decimal('123.45')
with patch('trytond.modules.purchase_trade.sale.super') as super_mock:
super_mock.return_value.compute_unit_price.return_value = None
self.assertEqual(
line.compute_unit_price(),
Decimal('123.45'))
def test_sale_line_linked_unit_factor_keeps_currency_factor_without_product(self):
'linked price conversion does not crash when automation builds a sale line before setting product'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.enable_linked_currency = True
line.linked_currency = Mock(factor=Decimal('2'))
line.linked_unit = Mock()
line.unit = None
self.assertEqual(line._get_linked_unit_factor(), Decimal('2'))
def test_get_sla_cost_returns_empty_tuple_when_no_place_matches(self):
'controller sla helper keeps a stable tuple contract when no sla rule matches'
Party = Pool().get('party.party')
party = Party()
party.sla = []
self.assertEqual(
party.get_sla_cost(Mock()),
(None, None, None, None))
def test_get_sla_cost_matches_country_without_location(self):
'controller sla helper can match a destination country'
Party = Pool().get('party.party')
party = Party()
sla = Mock(id=1)
party.sla = [sla]
country = Mock(id=10)
location = Mock(country=country)
country_place = Mock(
country=country,
location=None,
cost=Decimal('12'),
mode='ppack',
currency=Mock(id=1),
unit=Mock(id=2),
)
place_model = Mock()
place_model.search.return_value = [country_place]
with patch('trytond.modules.purchase_trade.party.Pool') as PoolMock:
PoolMock.return_value.get.return_value = place_model
self.assertEqual(
party.get_sla_cost(location),
(
country_place.cost,
country_place.mode,
country_place.currency,
country_place.unit,
))
def test_get_sla_cost_prioritizes_country_location_pair(self):
'controller sla helper prefers country and location over either alone'
Party = Pool().get('party.party')
party = Party()
sla = Mock(id=1)
party.sla = [sla]
country = Mock(id=10)
location = Mock(country=country)
country_only = Mock(
country=country,
location=None,
cost=Decimal('12'),
mode='ppack',
currency=Mock(id=1),
unit=Mock(id=2),
)
location_only = Mock(
country=None,
location=location,
cost=Decimal('14'),
mode='perqt',
currency=Mock(id=3),
unit=Mock(id=4),
)
pair = Mock(
country=country,
location=location,
cost=Decimal('16'),
mode='rate',
currency=Mock(id=5),
unit=Mock(id=6),
)
place_model = Mock()
place_model.search.return_value = [country_only, location_only, pair]
with patch('trytond.modules.purchase_trade.party.Pool') as PoolMock:
PoolMock.return_value.get.return_value = place_model
self.assertEqual(
party.get_sla_cost(location),
(pair.cost, pair.mode, pair.currency, pair.unit))
def test_get_party_by_name_adds_missing_category_to_existing_party(self):
'existing parties found by automation gain the requested category when missing'
Party = Pool().get('party.party')
existing_party = Mock(id=12)
category = Mock(id=34)
category_model = Mock()
category_model.search.return_value = [category]
party_category_model = Mock()
party_category_model.search.return_value = []
with patch.object(Party, 'search', return_value=[existing_party]), patch(
'trytond.modules.purchase_trade.party.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'party.category': category_model,
'party.party-party.category': party_category_model,
}[name]
party = Party.getPartyByName('SYED SP COT D BD', 'CLIENT')
self.assertIs(party, existing_party)
party_category_model.save.assert_called_once()
def test_create_fee_skips_when_controller_has_no_sla_cost(self):
'shipment fee creation skips cleanly when controller has no matching sla'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.id = 99
shipment.to_location = Mock()
shipment.get_bales = Mock(return_value=Decimal('10'))
controller = Mock()
controller.get_sla_cost.return_value = (None, None, None, None)
fee_model = Mock()
product_model = Mock()
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
'product.product': product_model,
}[name]
shipment.create_fee(controller)
fee_model.save.assert_not_called()
product_model.get_by_name.assert_not_called()
def test_shipment_started_cancel_deletes_draft_stock_account_moves(self):
'started shipment cancel removes draft account moves linked to stock moves'
ShipmentIn = Pool().get('stock.shipment.in')
draft_move = Mock(state='draft')
account_move = Mock()
account_move.search.return_value = [draft_move]
stock_moves = [Mock(id=10), Mock(id=20)]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = account_move
ShipmentIn._delete_draft_account_moves_for_stock_moves(stock_moves)
account_move.search.assert_called_once_with([
('origin', 'in', ['stock.move,10', 'stock.move,20']),
])
account_move.delete.assert_called_once_with([draft_move])
def test_shipment_started_cancel_refuses_posted_stock_account_moves(self):
'started shipment cancel is blocked by posted stock account moves'
ShipmentIn = Pool().get('stock.shipment.in')
posted_move = Mock(state='posted', rec_name='STJ/1')
account_move = Mock()
account_move.search.return_value = [posted_move]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = account_move
with self.assertRaises(UserError):
ShipmentIn._delete_draft_account_moves_for_stock_moves([
Mock(id=10),
])
account_move.delete.assert_not_called()
@with_transaction()
def test_shipment_in_type_detects_dropship(self):
'shipment type is dropship from supplier directly to customer'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.from_location = Mock(type='supplier')
shipment.to_location = Mock(type='customer')
self.assertEqual(shipment.get_shipment_type(), 'dropship')
@with_transaction()
def test_shipment_in_type_defaults_to_inbound(self):
'shipment type is inbound for non supplier-to-customer routes'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.from_location = Mock(type='supplier')
shipment.to_location = Mock(type='storage')
self.assertEqual(shipment.get_shipment_type(), 'inbound')
@with_transaction()
def test_lot_report_shipment_type_uses_linked_shipment(self):
'lot report shipment type mirrors the linked shipment_in'
LotReport = Pool().get('lot.report')
report = LotReport()
report.r_lot_shipment_in = Mock(shipment_type='dropship')
report.r_from_l = Mock(type='storage')
report.r_from_t = Mock(type='storage')
self.assertEqual(report.get_shipment_type(None), 'dropship')
@with_transaction()
def test_shipment_in_quantity_uses_lotqt_without_physical_moves(self):
'shipment quantity falls back to linked open lots before physical moves'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.incoming_moves = []
shipment.lotqt = [
Mock(lot_quantity=Decimal('100.50')),
Mock(lot_quantity=Decimal('20.25')),
]
self.assertEqual(shipment.get_quantity(), Decimal('120.75'))
def test_sof_quantity_uses_shipment_quantity_fallback(self):
'demurrage quantity uses shipment quantity including linked open lots'
sof = stock_module.StatementOfFacts()
sof.shipment = Mock(get_quantity=Mock(return_value=Decimal('442')))
self.assertEqual(sof.get_qt(None), Decimal('442'))
def test_fee_ppack_auto_uses_lot_unit_line_when_lot_unit_missing(self):
'per packing auto quantity falls back to lot_unit_line'
bale = Mock(factor=Decimal('1'))
pack = Mock(factor=Decimal('10'))
lot = Mock(lot_unit=None, lot_unit_line=bale)
lot.get_current_quantity_converted.side_effect = (
lambda state=0, unit=None: Decimal('25')
if unit else Decimal('250'))
fee = fee_module.Fee()
fee.line = Mock(lots=[lot])
fee.sale_line = None
fee.shipment_in = None
fee.mode = 'ppack'
fee.auto_calculation = True
fee.unit = pack
self.assertEqual(fee.on_change_with_quantity(), Decimal('25'))
def test_fee_currency_is_required(self):
'fee currency is required for every fee mode'
self.assertTrue(fee_module.Fee.currency.required)
def test_fee_get_non_cog_returns_zero_without_move_lines(self):
'fee non-cog amount is zero before any accounting move line exists'
fee = fee_module.Fee()
fee.id = 1
fee.product = Mock(account_stock_in_used=Mock(id=2))
lot = Mock(id=3)
move_line = Mock()
move_line.search.return_value = []
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'account.move.line': move_line,
'currency.currency': Mock(),
'ir.date': Mock(),
'account.configuration': Mock(return_value=Mock()),
'product.uom': Mock(),
}[name]
self.assertEqual(fee.get_non_cog(lot), Decimal(0))
def test_fee_get_cog_returns_zero_without_move_lines(self):
'fee cog amount is zero before any stock accounting move line exists'
fee = fee_module.Fee()
fee.id = 1
fee.product = Mock(account_stock_in_used=Mock(id=2))
lot = Mock(id=3)
move_line = Mock()
move_line.search.return_value = []
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'account.move.line': move_line,
'currency.currency': Mock(),
'ir.date': Mock(),
'account.configuration': Mock(return_value=Mock()),
'product.uom': Mock(),
}[name]
self.assertEqual(fee.get_cog(lot), Decimal(0))
def test_valuation_rejects_fee_without_currency_cleanly(self):
'pnl generation reports a missing fee currency before currency conversion'
fee = Mock(currency=None, product=Mock(name='Maritime freight'))
with self.assertRaises(UserError) as cm:
valuation_module.ValuationBase._check_fee_required_fields(fee)
self.assertIn('Currency is required on fee', str(cm.exception))
def test_purchase_line_default_pricing_rule_comes_from_configuration(self):
'purchase line pricing_rule defaults to the purchase_trade singleton value'
PurchaseLine = Pool().get('purchase.line')
config = Mock(pricing_rule='Default pricing rule')
configuration_model = Mock()
configuration_model.search.return_value = [config]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = configuration_model
self.assertEqual(
PurchaseLine.default_pricing_rule(), 'Default pricing rule')
def test_purchase_delete_blocks_matched_physical_contract(self):
'purchase delete stops when a physical lot already links purchase and sale'
Purchase = Pool().get('purchase.purchase')
matched_lot = Mock(lot_type='physic', line=Mock(), sale_line=Mock())
purchase = Mock(lines=[Mock(lots=[matched_lot])])
with self.assertRaises(UserError):
Purchase.delete([purchase])
def test_purchase_delete_delegates_when_no_matched_physical_lot(self):
'purchase delete keeps default flow when no matched physical lot exists'
Purchase = Pool().get('purchase.purchase')
purchase = Mock(lines=[Mock(lots=[Mock(lot_type='virtual')])])
with patch('trytond.modules.purchase_trade.purchase.super') as super_mock:
Purchase.delete([purchase])
super_mock.return_value.delete.assert_called_once_with([purchase])
def test_sale_delete_blocks_matched_physical_contract(self):
'sale delete stops when a physical lot already links sale and purchase'
Sale = Pool().get('sale.sale')
matched_lot = Mock(lot_type='physic', line=Mock(), sale_line=Mock())
sale = Mock(lines=[Mock(lots=[matched_lot])])
with self.assertRaises(UserError):
Sale.delete([sale])
def test_sale_delete_delegates_when_no_matched_physical_lot(self):
'sale delete keeps default flow when no matched physical lot exists'
Sale = Pool().get('sale.sale')
sale = Mock(lines=[Mock(lots=[Mock(lot_type='virtual')])])
with patch('trytond.modules.purchase_trade.sale.super') as super_mock:
Sale.delete([sale])
super_mock.return_value.delete.assert_called_once_with([sale])
def test_component_quota_uses_quantity_fallback_when_theoretical_is_missing(self):
'component quota does not crash when theoretical quantity is still empty'
SaleComponent = Pool().get('pricing.component')
PurchaseComponent = Pool().get('pricing.component')
sale_component = SaleComponent()
sale_component.nbdays = None
sale_component.sale_line = Mock(
quantity=Decimal('12'),
quantity_theorical=None,
)
purchase_component = PurchaseComponent()
purchase_component.nbdays = None
purchase_component.line = Mock(
quantity=Decimal('15'),
quantity_theorical=None,
)
self.assertEqual(sale_component.get_quota_sale('quota_sale'), Decimal('12.0000'))
self.assertEqual(
purchase_component.get_quota_purchase('quota'),
Decimal('15.00000'))
def test_sale_and_purchase_generate_pricing_use_quantity_fallback(self):
'pricing generation uses quantity when theoretical quantity is missing'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
sale = Sale()
sale.id = 1
sale.quantity = Decimal('10')
sale.quantity_theorical = None
sale.unit = Mock()
sale.sale = Mock(currency=Mock())
sale.getnearprice = Mock(return_value=Decimal('0'))
purchase = Purchase()
purchase.id = 1
purchase.quantity = Decimal('12')
purchase.quantity_theorical = None
purchase.unit = Mock()
purchase.purchase = Mock(currency=Mock())
purchase.getnearprice = Mock(return_value=Decimal('0'))
pricing_model = Mock()
pricing_model.search.return_value = []
pc_sale = Mock(
id=1, quota_sale=Decimal('2'), pricing_date=None,
get_price=Mock(return_value=Decimal('1')))
pc_purchase = Mock(
id=1, quota=Decimal('3'), pricing_date=None,
get_price=Mock(return_value=Decimal('1')))
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool, patch(
'trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
SalePool.return_value.get.return_value = pricing_model
PurchasePool.return_value.get.return_value = pricing_model
sale.generate_pricing(pc_sale, [datetime.datetime(2026, 4, 1)], [])
purchase.generate_pricing(pc_purchase, [datetime.datetime(2026, 4, 1)], [])
self.assertEqual(pricing_model.save.call_args_list[0].args[0][0].unfixed_qt, Decimal('10'))
self.assertEqual(pricing_model.save.call_args_list[1].args[0][0].unfixed_qt, Decimal('12'))
def test_trade_line_quantity_is_readonly(self):
'trade line quantity is a technical counter, not an editable business input'
SaleLine = Pool().get('sale.line')
PurchaseLine = Pool().get('purchase.line')
self.assertTrue(SaleLine.quantity.readonly)
self.assertTrue(PurchaseLine.quantity.readonly)
def test_purchase_line_initial_quantity_uses_theoretical_quantity(self):
'purchase line initializes technical quantity from contractual quantity'
PurchaseLine = Pool().get('purchase.line')
line = Mock(
quantity=None,
quantity_theorical=Decimal('42'),
lots=[],
)
self.assertTrue(PurchaseLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('42.00000'))
def test_sale_line_initial_quantity_uses_theoretical_quantity(self):
'sale line initializes technical quantity from contractual quantity'
SaleLine = Pool().get('sale.line')
line = Mock(
quantity=Decimal('0'),
quantity_theorical=Decimal('24'),
lots=[],
)
self.assertTrue(SaleLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('24.00000'))
def test_trade_line_initial_quantity_does_not_override_existing_counter(self):
'initial sync keeps an existing technical quantity'
PurchaseLine = Pool().get('purchase.line')
physical = Mock(lot_type='virtual')
line = Mock(
quantity=Decimal('10'),
quantity_theorical=Decimal('20'),
lots=[physical],
)
self.assertFalse(PurchaseLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('10'))
def test_trade_line_initial_quantity_does_not_run_with_physical_lot(self):
'initial sync never overwrites the executed physical quantity'
SaleLine = Pool().get('sale.line')
physical = Mock(lot_type='physic')
line = Mock(
quantity=Decimal('0'),
quantity_theorical=Decimal('20'),
lots=[physical],
)
self.assertFalse(SaleLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('0'))
def test_purchase_line_amount_uses_theoretical_quantity_until_finished(self):
'purchase line amount uses contractual quantity while line remains open'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = False
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = None
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
def test_purchase_line_amount_uses_physical_counter_when_finished(self):
'purchase line amount uses executed quantity once line is finished'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = None
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
def test_purchase_line_amount_uses_purchase_weight_basis_when_finished(self):
'purchase line amount uses purchase weight basis for physical lots'
PurchaseLine = Pool().get('purchase.line')
bl = Mock(id=1)
unit = Mock()
lot = Mock(
lot_type='physic',
lot_hist=[Mock(quantity_type=bl)],
)
lot.get_current_quantity_converted.return_value = Decimal('8')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = Mock(wb=Mock(qt_type=bl))
line.unit = unit
line.lots = [lot]
self.assertEqual(line.on_change_with_amount(), Decimal('16'))
lot.get_current_quantity_converted.assert_called_once_with(1, unit)
def test_sale_line_amount_uses_theoretical_quantity_until_finished(self):
'sale line amount uses contractual quantity while line remains open'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = False
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = None
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
def test_sale_line_amount_uses_physical_counter_when_finished(self):
'sale line amount uses executed quantity once line is finished'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = None
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
def test_sale_line_amount_uses_sale_weight_basis_when_finished(self):
'sale line amount uses sale weight basis for physical lots'
SaleLine = Pool().get('sale.line')
bl = Mock(id=1)
lr = Mock(id=2)
unit = Mock()
lot = Mock(
lot_type='physic',
lot_hist=[Mock(quantity_type=bl), Mock(quantity_type=lr)],
)
lot.get_current_quantity_converted.return_value = Decimal('7')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('8')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = Mock(wb=Mock(qt_type=lr))
line.unit = unit
line.lots = [lot]
self.assertEqual(line.on_change_with_amount(), Decimal('14'))
lot.get_current_quantity_converted.assert_called_once_with(2, unit)
def test_pricing_manual_fields_are_editable_except_eod(self):
'manual pricing rows only edit quantity and settlement while derived values stay computed'
Pricing = Pool().get('pricing.pricing')
self.assertFalse(Pricing.quantity.readonly)
self.assertFalse(Pricing.settl_price.readonly)
self.assertTrue(Pricing.fixed_qt.readonly)
self.assertTrue(Pricing.fixed_qt_price.readonly)
self.assertTrue(Pricing.unfixed_qt.readonly)
self.assertTrue(Pricing.unfixed_qt_price.readonly)
self.assertTrue(Pricing.eod_price.readonly)
def test_pricing_component_domain_is_limited_to_owner_line(self):
'pricing component choices are limited to the purchase or sale line'
Pricing = Pool().get('pricing.pricing')
self.assertEqual(Pricing.price_component.domain, [
('id', 'in', Eval('available_components', [])),
])
def test_pricing_defaults_single_manual_purchase_component(self):
'new purchase pricing rows default the only manual line component'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
component = Mock(id=33, auto=False)
component_model = Mock()
component_model.search.return_value = [component]
pool = Mock()
pool.get.return_value = component_model
pricing = Pricing()
pricing.line = owner
pricing.sale_line = None
pricing.price_component = None
with patch('trytond.modules.purchase_trade.pricing.Pool',
return_value=pool):
pricing.on_change_line()
component_model.search.assert_called_once_with([('line', '=', owner)])
self.assertEqual(pricing.price_component, component)
def test_pricing_defaults_single_manual_sale_component(self):
'new sale pricing rows default the only manual line component'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
component = Mock(id=33, auto=False)
component_model = Mock()
component_model.search.return_value = [component]
pool = Mock()
pool.get.return_value = component_model
pricing = Pricing()
pricing.line = None
pricing.sale_line = owner
pricing.price_component = None
with patch('trytond.modules.purchase_trade.pricing.Pool',
return_value=pool):
pricing.on_change_sale_line()
component_model.search.assert_called_once_with([
('sale_line', '=', owner)])
self.assertEqual(pricing.price_component, component)
def test_pricing_does_not_default_single_auto_component(self):
'new manual pricing rows do not default an auto component'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
component_model = Mock()
component_model.search.return_value = [Mock(id=33, auto=True)]
pool = Mock()
pool.get.return_value = component_model
pricing = Pricing()
pricing.line = owner
pricing.sale_line = None
pricing.price_component = None
with patch('trytond.modules.purchase_trade.pricing.Pool',
return_value=pool):
pricing.on_change_line()
self.assertIsNone(pricing.price_component)
def test_pricing_component_default_triggers_copy_first_purchase_component(self):
'new purchase components inherit trigger values from the first component'
Component = Pool().get('pricing.component')
pricing_period = Mock(id=21)
application_period = Mock(id=22)
first_component = Mock(triggers=[
Mock(
pricing_period=pricing_period,
from_p=datetime.date(2026, 4, 1),
to_p=datetime.date(2026, 4, 5),
average=True,
last=False,
application_period=application_period,
from_a=datetime.date(2026, 5, 1),
to_a=datetime.date(2026, 5, 5),
),
])
with patch.object(Component, 'search',
return_value=[first_component]) as search:
values = Component._default_trigger_values(line=10)
search.assert_called_once_with(
[('line', '=', 10)], order=[('id', 'ASC')], limit=1)
self.assertEqual(values, [{
'pricing_period': 21,
'from_p': datetime.date(2026, 4, 1),
'to_p': datetime.date(2026, 4, 5),
'average': True,
'last': False,
'application_period': 22,
'from_a': datetime.date(2026, 5, 1),
'to_a': datetime.date(2026, 5, 5),
}])
def test_pricing_component_create_inherits_first_sale_component_triggers(self):
'component create copies triggers from the first sale line component'
Component = Pool().get('pricing.component')
first_component = Mock(triggers=[
Mock(
pricing_period=Mock(id=21),
from_p=datetime.date(2026, 4, 1),
to_p=datetime.date(2026, 4, 5),
average=True,
last=True,
application_period=None,
from_a=None,
to_a=None,
),
])
with patch.object(Component, 'search',
return_value=[first_component]) as search, patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Component.create([{'sale_line': 10, 'price_source_type': 'curve'}])
search.assert_called_once_with(
[('sale_line', '=', 10)], order=[('id', 'ASC')], limit=1)
created_values = super_mock.return_value.create.call_args.args[0][0]
self.assertEqual(created_values['triggers'], [('create', [{
'pricing_period': 21,
'from_p': datetime.date(2026, 4, 1),
'to_p': datetime.date(2026, 4, 5),
'average': True,
'last': True,
'application_period': None,
'from_a': None,
'to_a': None,
}])])
def test_pricing_component_create_keeps_explicit_triggers(self):
'component create does not replace triggers already provided'
Component = Pool().get('pricing.component')
explicit_triggers = [('create', [{'average': False}])]
with patch.object(Component, 'search') as search, patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Component.create([{
'line': 10,
'price_source_type': 'curve',
'triggers': explicit_triggers,
}])
search.assert_not_called()
created_values = super_mock.return_value.create.call_args.args[0][0]
self.assertIs(created_values['triggers'], explicit_triggers)
def test_pricing_component_must_belong_to_pricing_owner(self):
'pricing rows reject components from another purchase or sale line'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
other = Mock(id=11)
self.assertTrue(Pricing._component_matches_owner(Mock(
line=owner,
sale_line=None,
price_component=Mock(line=owner, sale_line=None),
)))
self.assertFalse(Pricing._component_matches_owner(Mock(
line=owner,
sale_line=None,
price_component=Mock(line=other, sale_line=None),
)))
self.assertTrue(Pricing._component_matches_owner(Mock(
line=None,
sale_line=owner,
price_component=Mock(line=None, sale_line=owner),
)))
self.assertFalse(Pricing._component_matches_owner(Mock(
line=None,
sale_line=owner,
price_component=Mock(line=None, sale_line=other),
)))
def test_purchase_and_sale_delivery_period_reject_from_after_to(self):
'delivery period dates must be chronological on purchase and sale lines'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
invalid = Mock(
from_del=datetime.date(2026, 5, 1),
to_del=datetime.date(2026, 4, 30),
)
valid = Mock(
from_del=datetime.date(2026, 4, 1),
to_del=datetime.date(2026, 4, 30),
)
self.assertTrue(PurchaseLine._has_invalid_delivery_period(invalid))
self.assertTrue(SaleLine._has_invalid_delivery_period(invalid))
self.assertFalse(PurchaseLine._has_invalid_delivery_period(valid))
self.assertFalse(SaleLine._has_invalid_delivery_period(valid))
purchase_line = PurchaseLine()
purchase_line.from_del = invalid.from_del
purchase_line.to_del = invalid.to_del
sale_line = SaleLine()
sale_line.from_del = invalid.from_del
sale_line.to_del = invalid.to_del
with self.assertRaises(UserError):
purchase_line.on_change_from_del()
with self.assertRaises(UserError):
sale_line.on_change_to_del()
with self.assertRaises(UserError):
PurchaseLine._check_delivery_period_values([valid], {
'from_del': invalid.from_del,
})
with self.assertRaises(UserError):
SaleLine._check_delivery_period_values([valid], {
'to_del': datetime.date(2026, 3, 31),
})
with self.assertRaises(UserError):
PurchaseLine._check_delivery_period_values([invalid], {
'quantity': Decimal('1'),
})
with self.assertRaises(UserError):
SaleLine._check_delivery_period_values([invalid], {
'quantity': Decimal('1'),
})
def test_sale_and_purchase_parent_write_check_embedded_line_commands(self):
'sale and purchase writes validate embedded one2many line commands'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
invalid = Mock(
from_del=datetime.date(2026, 4, 16),
to_del=datetime.date(2026, 4, 10),
)
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool:
SalePool.return_value.get.return_value = Mock(
browse=Mock(return_value=[invalid]))
with self.assertRaises(UserError):
Sale._check_lines_delivery_period_values({
'lines': [['write', [564], {'quantity': Decimal('1')}]],
})
with patch('trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
PurchasePool.return_value.get.return_value = Mock(
browse=Mock(return_value=[invalid]))
with self.assertRaises(UserError):
Purchase._check_lines_delivery_period_values({
'lines': [['add', [564]]],
})
def test_parent_write_checks_final_embedded_line_values(self):
'parent writes validate final line values after add and write commands'
Sale = Pool().get('sale.sale')
line = Mock(
from_del=datetime.date(2026, 4, 21),
to_del=datetime.date(2026, 4, 10),
)
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool:
SalePool.return_value.get.return_value = Mock(
browse=Mock(return_value=[line]))
Sale._check_lines_delivery_period_values({
'lines': [
['add', [564]],
['write', [564], {
'from_del': datetime.date(2026, 4, 6),
}],
],
})
def test_pricing_eod_uses_weighted_average_for_manual_rows(self):
'manual pricing eod uses the weighted average of fixed and unfixed legs'
Pricing = Pool().get('pricing.pricing')
pricing = Pricing()
pricing.fixed_qt = Decimal('4')
pricing.fixed_qt_price = Decimal('100')
pricing.unfixed_qt = Decimal('6')
pricing.unfixed_qt_price = Decimal('110')
self.assertEqual(pricing.compute_eod_price(), Decimal('106.0000'))
def test_sale_and_purchase_eod_use_same_weighted_formula(self):
'auto sale/purchase eod helpers use the same weighted average formula'
Pricing = Pool().get('pricing.pricing')
sale_pricing = Pricing()
sale_pricing.sale_line = Mock(quantity=Decimal('999'))
sale_pricing.fixed_qt = Decimal('4')
sale_pricing.fixed_qt_price = Decimal('100')
sale_pricing.unfixed_qt = Decimal('6')
sale_pricing.unfixed_qt_price = Decimal('110')
purchase_pricing = Pricing()
purchase_pricing.line = Mock(quantity_theorical=Decimal('999'))
purchase_pricing.fixed_qt = Decimal('4')
purchase_pricing.fixed_qt_price = Decimal('100')
purchase_pricing.unfixed_qt = Decimal('6')
purchase_pricing.unfixed_qt_price = Decimal('110')
self.assertEqual(sale_pricing.get_eod_price_sale(), Decimal('106.0000'))
self.assertEqual(
purchase_pricing.get_eod_price_purchase(), Decimal('106.0000'))
def test_pricing_sync_manual_last_uses_greatest_date_per_component_group(self):
'pricing rows keep one last by line/component, chosen by greatest pricing date'
Pricing = Pool().get('pricing.pricing')
sale_line = Mock(id=10)
component = Mock(id=33)
first = Mock(
id=1,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 10),
last=True,
)
second = Mock(
id=2,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 9),
last=False,
)
with patch.object(Pricing, 'search', return_value=[second, first]), patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Pricing._sync_manual_last([first, second])
self.assertEqual(super_mock.return_value.write.call_args_list[0].args[0], [second])
self.assertEqual(super_mock.return_value.write.call_args_list[0].args[1], {'last': False})
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[0], [first])
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[1], {'last': True})
def test_pricing_sync_manual_values_recomputes_manual_group_from_quantity_and_settl(self):
'manual pricing rows derive fixed/unfixed values from quantity and settlement price'
Pricing = Pool().get('pricing.pricing')
sale_line = Mock(id=10)
sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10'))
component = Mock(id=33, auto=False)
first = Mock(
id=1,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 10),
quantity=Decimal('4'),
settl_price=Decimal('100'),
)
second = Mock(
id=2,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 11),
quantity=Decimal('3'),
settl_price=Decimal('110'),
)
with patch.object(Pricing, 'search', return_value=[first, second]), patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Pricing._sync_manual_values([first, second])
first_values = super_mock.return_value.write.call_args_list[0].args[1]
self.assertEqual(first_values['fixed_qt'], Decimal('4'))
self.assertEqual(first_values['fixed_qt_price'], Decimal('100.0000'))
self.assertEqual(first_values['unfixed_qt'], Decimal('6'))
self.assertEqual(first_values['unfixed_qt_price'], Decimal('100'))
self.assertEqual(first_values['eod_price'], Decimal('100.0000'))
self.assertFalse(first_values['last'])
second_values = super_mock.return_value.write.call_args_list[1].args[1]
self.assertEqual(second_values['fixed_qt'], Decimal('7'))
self.assertEqual(second_values['fixed_qt_price'], Decimal('104.2857'))
self.assertEqual(second_values['unfixed_qt'], Decimal('3'))
self.assertEqual(second_values['unfixed_qt_price'], Decimal('110'))
self.assertEqual(second_values['eod_price'], Decimal('106.0000'))
self.assertTrue(second_values['last'])
def test_pricing_sync_manual_values_uses_curve_last_price_for_unfixed(self):
'manual pricing rows use the component curve latest price for the unfixed leg'
Pricing = Pool().get('pricing.pricing')
currency = Mock()
unit = Mock()
sale_line = Mock(
id=10,
sale=Mock(currency=currency),
unit=unit,
)
sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10'))
component = Mock(
id=33,
auto=False,
price_source_type='curve',
price_index=Mock(),
)
component.get_price.return_value = Decimal('120')
pricing = Mock(
id=1,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 10),
quantity=Decimal('4'),
settl_price=Decimal('100'),
)
with patch.object(Pricing, 'search', return_value=[pricing]), patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Pricing._sync_manual_values([pricing])
values = super_mock.return_value.write.call_args.args[1]
self.assertEqual(values['unfixed_qt_price'], Decimal('120.0000'))
self.assertEqual(values['eod_price'], Decimal('112.0000'))
component.get_price.assert_called_once_with(
datetime.date(2026, 4, 10), unit, currency, True)
def test_pricing_trigger_manual_from_to_generates_dates_without_calendar(self):
'manual pricing From/To uses calendar days when no calendar is selected'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.pricing_period = None
trigger.from_p = datetime.date(2026, 4, 1)
trigger.to_p = datetime.date(2026, 4, 3)
trigger.getprice = Mock(side_effect=lambda date: {
'date': date,
'price': Decimal('100'),
'avg': Decimal('100'),
'avg_minus_1': Decimal('100'),
'isAvg': False,
})
dates, prices = trigger.getPricingListDates(None)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 1),
datetime.date(2026, 4, 2),
datetime.date(2026, 4, 3),
])
self.assertEqual(len(prices), 3)
def test_pricing_period_every_positive_returns_weekdays_after_trigger(self):
'Every Friday and 4 returns the four Fridays from/after trigger date'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.every = 'friday'
period.nb_quotation = 4
_, _, dates = period.getDates(datetime.date(2026, 4, 1))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 3),
datetime.date(2026, 4, 10),
datetime.date(2026, 4, 17),
datetime.date(2026, 4, 24),
])
def test_pricing_period_every_negative_returns_weekdays_before_trigger(self):
'Every Friday and -4 returns the four Fridays before trigger date'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.every = 'friday'
period.nb_quotation = -4
_, _, dates = period.getDates(datetime.date(2026, 4, 30))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 24),
datetime.date(2026, 4, 17),
datetime.date(2026, 4, 10),
datetime.date(2026, 4, 3),
])
def test_pricing_period_blank_every_positive_uses_available_dates_after_trigger(self):
'Blank Every and positive Nb quotation returns dates after trigger'
Period = Pool().get('pricing.period')
calendar = Mock()
excluded = {
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 16),
}
calendar.IsQuote = Mock(
side_effect=lambda date: date.date() not in excluded)
period = Period()
period.trigger = 'bldate'
period.every = None
period.nb_quotation = 4
_, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 15),
datetime.date(2026, 5, 17),
datetime.date(2026, 5, 18),
])
def test_pricing_period_blank_every_include_counts_trigger_plus_after_dates(self):
'Blank Every with Inc. returns trigger date plus quotations after'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.include = True
period.every = None
period.nb_quotation = 4
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 12),
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 15),
datetime.date(2026, 5, 16),
])
def test_pricing_period_blank_every_negative_uses_available_dates_before_trigger(self):
'Blank Every and negative Nb quotation also returns dates before trigger'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.every = None
period.nb_quotation = -4
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 8),
datetime.date(2026, 5, 9),
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 11),
])
def test_pricing_period_before_after_returns_symmetric_dates(self):
'Before/After returns quotations before and after trigger'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.include = False
period.every = 'before_after'
period.nb_quotation = 2
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 11),
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 14),
])
def test_pricing_period_before_after_include_adds_trigger_date(self):
'Before/After with Inc. adds trigger date between both sides'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.include = True
period.every = 'before_after'
period.nb_quotation = 2
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 11),
datetime.date(2026, 5, 12),
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 14),
])
def test_pricing_period_before_after_skips_calendar_exclusions(self):
'Before/After uses available quotations around trigger date'
Period = Pool().get('pricing.period')
calendar = Mock()
excluded = {
datetime.date(2026, 5, 11),
datetime.date(2026, 5, 13),
}
calendar.IsQuote = Mock(
side_effect=lambda date: date.date() not in excluded)
period = Period()
period.trigger = 'bldate'
period.include = True
period.every = 'before_after'
period.nb_quotation = 2
_, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 9),
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 12),
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 15),
])
def test_pricing_trigger_application_period_defaults_to_pricing_period(self):
'application period defaults to pricing period without being cleared'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.pricing_period = Mock(id=21)
trigger.application_period = None
self.assertEqual(
trigger.on_change_with_application_period(),
trigger.pricing_period)
def test_pricing_trigger_application_period_keeps_existing_value(self):
'application period keeps explicit value when pricing period changes'
Trigger = Pool().get('pricing.trigger')
application_period = Mock(id=22)
trigger = Trigger()
trigger.pricing_period = Mock(id=21)
trigger.application_period = application_period
self.assertEqual(
trigger.on_change_with_application_period(),
application_period)
def test_purchase_line_get_avg_accepts_decimal_matrix_prices(self):
'purchase pricing averages Decimal matrix prices without float mixing'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
prices = [
{'price': Decimal('45.0000')},
{'price': Decimal('55.0000')},
]
result = line.get_avg(prices)
self.assertEqual(result[0]['avg'], Decimal('45.0000'))
self.assertEqual(result[1]['avg_minus_1'], Decimal('45.0000'))
self.assertEqual(result[1]['avg'], Decimal('50.0000'))
def test_sale_line_get_avg_accepts_decimal_matrix_prices(self):
'sale pricing averages Decimal matrix prices without float mixing'
SaleLine = Pool().get('sale.line')
line = SaleLine()
prices = [
{'price': Decimal('45.0000')},
{'price': Decimal('55.0000')},
]
result = line.get_avg(prices)
self.assertEqual(result[0]['avg'], Decimal('45.0000'))
self.assertEqual(result[1]['avg_minus_1'], Decimal('45.0000'))
self.assertEqual(result[1]['avg'], Decimal('50.0000'))
def test_pricing_trigger_manual_application_defaults_to_pricing_from_to(self):
'empty manual application From/To falls back to manual pricing From/To'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.pricing_period = None
trigger.application_period = None
trigger.from_p = datetime.date(2026, 4, 1)
trigger.to_p = datetime.date(2026, 4, 2)
trigger.from_a = None
trigger.to_a = None
dates, prices = trigger.getApplicationListDates(None)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 1),
datetime.date(2026, 4, 2),
])
self.assertEqual(prices, [])
def test_pricing_trigger_on_change_copies_manual_pricing_dates_to_application(self):
'manual pricing From/To is copied to empty application From/To in the UI'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.application_period = None
trigger.from_p = datetime.date(2026, 4, 1)
trigger.to_p = datetime.date(2026, 4, 30)
trigger.from_a = None
trigger.to_a = None
trigger.on_change_from_p()
trigger.on_change_to_p()
self.assertEqual(trigger.from_a, datetime.date(2026, 4, 1))
self.assertEqual(trigger.to_a, datetime.date(2026, 4, 30))
def test_pricing_trigger_average_defaults_to_true_and_last_modes(self):
'pricing trigger defaults to Avg and exposes AL/RL last modes'
Trigger = Pool().get('pricing.trigger')
self.assertTrue(Trigger.default_average())
self.assertEqual(Trigger.last.string, 'AL')
self.assertEqual(Trigger.relative_last.string, 'RL')
@with_transaction()
def test_import_forward_prices_copies_value_to_ohl_fields(self):
'forward import uses price values for open, low and high prices'
ImportPrices = Pool().get('purchase_trade.import_prices')
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheet = ElementTree.fromstring('''
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
<c r="C1" t="inlineStr"><is><t>2026-03</t></is></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>ICE Cotton</t></is></c>
<c r="B2" t="inlineStr"><is><t>2026-03-01</t></is></c>
<c r="C2"><v>75.25</v></c>
</row>
</sheetData>
</worksheet>
''')
rows = sheet.findall('.//s:sheetData/s:row', ns)
result = ImportPrices._read_forward_price_rows(rows, [], ns)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['price_value'], '75.25')
self.assertEqual(result[0]['open_price'], '75.25')
self.assertEqual(result[0]['low_price'], '75.25')
self.assertEqual(result[0]['high_price'], '75.25')
def test_pricing_component_matrix_returns_generic_line_price(self):
'matrix pricing can use an unconditional matrix line as a component price'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'matrix'
component.price_matrix = Mock(
id=10,
valid_from=datetime.date(2026, 1, 1),
valid_to=datetime.date(2026, 12, 31),
unit=None,
currency=None,
)
component.line = Mock(
product=Mock(),
purchase=Mock(from_location=Mock(), to_location=Mock()))
component.sale_line = None
matrix_line = Mock(
origin=None,
destination=None,
product=None,
quality=None,
price_value=Decimal('45'))
matrix_model = Mock(search=Mock(return_value=[matrix_line]))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = matrix_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False)
self.assertEqual(price, Decimal('45.0000'))
def test_pricing_component_curve_passes_relative_last(self):
'curve pricing can request the price relative last before target date'
Component = Pool().get('pricing.component')
price_index = Mock()
component = Component()
component.price_source_type = 'curve'
component.price_index = price_index
price_model = Mock(return_value=price_index)
price_index.get_price = Mock(return_value=Decimal('101'))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = price_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False, True)
self.assertEqual(price, Decimal('101'))
price_index.get_price.assert_called_once_with(
datetime.date(2026, 4, 1), ANY, ANY, False, True)
def test_price_get_price_relative_last_uses_latest_before_date(self):
'relative last picks latest available price before target date'
Price = Pool().get('price.price')
price = Price()
price.id = 42
price.price_values = [Mock()]
price.get_price_per_qt = Mock(return_value=Decimal('88'))
previous_value = Mock(price_value=Decimal('88'))
price_value_model = Mock()
price_value_model.search = Mock(side_effect=[[], [previous_value]])
with patch('trytond.modules.price.price.Pool') as PricePool:
PricePool.return_value.get.return_value = price_value_model
result = price.get_price(
datetime.date(2026, 4, 10), Mock(), Mock(),
last=False, relative_last=True)
self.assertEqual(result, Decimal('88'))
price_value_model.search.assert_any_call([
('price', '=', 42),
('price_date', '<=', '2026-04-10'),
], order=[('price_date', 'DESC')])
def test_pricing_component_matrix_prefers_specific_line_over_generic(self):
'matrix pricing keeps generic lines as fallback below matching lines'
Component = Pool().get('pricing.component')
origin = Mock(id=1)
component = Component()
component.price_source_type = 'matrix'
component.price_matrix = Mock(
id=10,
valid_from=None,
valid_to=None,
unit=None,
currency=None,
)
component.line = Mock(
product=Mock(id=20),
purchase=Mock(from_location=origin, to_location=Mock(id=2)))
component.sale_line = None
generic = Mock(
origin=None,
destination=None,
product=None,
quality=None,
price_value=Decimal('45'))
specific = Mock(
origin=origin,
destination=None,
product=None,
quality=None,
price_value=Decimal('43'))
matrix_model = Mock(search=Mock(return_value=[generic, specific]))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = matrix_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False)
self.assertEqual(price, Decimal('43.0000'))
def test_pricing_component_matrix_uses_matrix_calendar_fallback(self):
'matrix calendar is used when the component has no own calendar'
Component = Pool().get('pricing.component')
calendar = Mock()
component = Component()
component.calendar = None
component.price_matrix = Mock(calendar=calendar)
self.assertEqual(component.get_calendar(), calendar)
def test_pricing_component_fixed_returns_flat_price(self):
'fixed pricing returns the component flat price'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'fixed'
component.fixed_price = Decimal('125.25')
currency = Mock(id=1)
component.fixed_currency = currency
self.assertEqual(
component.get_price(
datetime.date(2026, 4, 1), Mock(), currency, False),
Decimal('125.2500'))
def test_pricing_component_fixed_converts_currency(self):
'fixed pricing converts from component currency to requested currency'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'fixed'
component.fixed_price = Decimal('100')
component.fixed_currency = Mock(id=1)
target_currency = Mock(id=2)
currency_model = Mock(
_get_rate=Mock(return_value={
component.fixed_currency.id: Decimal('1.2')}))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = currency_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), target_currency, False)
self.assertEqual(price, Decimal('120.0000'))
def test_sale_and_purchase_trader_operator_domains_use_explicit_categories(self):
'sale and purchase trader/operator fields are filtered by TRADER/OPERATOR categories'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
ShipmentIn = Pool().get('stock.shipment.in')
self.assertEqual(
Sale.trader.domain, [('categories.name', '=', 'TRADER')])
self.assertEqual(
Sale.operator.domain, [('categories.name', '=', 'OPERATOR')])
self.assertEqual(
Purchase.trader.domain, [('categories.name', '=', 'TRADER')])
self.assertEqual(
Purchase.operator.domain, [('categories.name', '=', 'OPERATOR')])
self.assertEqual(
ShipmentIn.operator.domain, [('categories.name', '=', 'OPERATOR')])
def test_shipment_in_pnl_lines_use_physical_and_open_lots(self):
'shipment PnL uses valuation lines from physical and open lot links'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.id = 99
physical_lot = Mock(id=1)
open_purchase_lot = Mock(id=2)
open_sale_lot = Mock(id=3)
shipment.incoming_moves = [
Mock(lot=physical_lot),
Mock(lot=None),
]
shipment.lotqt = [
Mock(lot_p=open_purchase_lot, lot_s=open_sale_lot),
Mock(lot_p=open_purchase_lot, lot_s=None),
]
valuation_model = Mock()
valuation_model.search.return_value = [Mock(id=10), Mock(id=11)]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = valuation_model
self.assertEqual(shipment.get_pnl_lines(None), [10, 11])
valuation_model.search.assert_called_once_with(
[('shipment_in', '=', 99)],
order=[('date', 'DESC'), ('id', 'DESC')])
def test_sale_and_purchase_tolerance_option_defaults_to_empty(self):
'sale and purchase tolerance option defaults to empty'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
self.assertEqual(Sale.default_tolerance_option(), '')
self.assertEqual(Purchase.default_tolerance_option(), '')
def test_purchase_line_premium_sums_decomposition_base_amounts(self):
'purchase line premium is the sum of premium decomposition base amounts'
Line = Pool().get('purchase.line')
line = Line()
line.premium_decomposition = [
Mock(base_amount=Decimal('12.50')),
Mock(base_amount=Decimal('-2.25')),
]
self.assertEqual(line.get_premium('premium'), Decimal('10.2500'))
def test_sale_line_premium_price_falls_back_to_decomposition(self):
'sale line premium price works when the function field is not loaded'
class LineWithoutPremium:
premium_decomposition = [
Mock(base_amount=Decimal('7.25')),
Mock(base_amount=Decimal('-1.00')),
]
get_premium = sale_module.SaleLine.get_premium
line = LineWithoutPremium()
self.assertEqual(
sale_module.SaleLine._get_premium_price(line),
Decimal('6.2500'))
def test_purchase_and_sale_line_premium_fields_are_readonly_totals(self):
'line premium fields are readonly totals from premium decomposition'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
self.assertTrue(PurchaseLine.premium.readonly)
self.assertTrue(SaleLine.premium.readonly)
def test_premium_composition_base_amount_converts_on_contract_date(self):
'premium composition base amount converts to contract currency at spot date'
Premium = Pool().get('premium.composition')
premium = Premium()
source_currency = Mock(id=1)
target_currency = Mock(id=2)
premium.premium = Decimal('100')
premium.currency = source_currency
premium.line = Mock(purchase=Mock(
currency=target_currency,
purchase_date=datetime.date(2026, 5, 1)))
currency_model = Mock(
compute=Mock(return_value=Decimal('120')))
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
PoolMock.return_value.get.return_value = currency_model
amount = premium.get_base_amount('base_amount')
self.assertEqual(amount, Decimal('120.000000'))
def test_fee_rule_matches_purchase_line_conditions(self):
'fee rule matches purchase line conditions'
party = Mock(id=1)
product = Mock(id=2)
incoterm = Mock(id=3)
from_location = Mock(id=4)
to_location = Mock(id=5)
plan = Mock(id=6)
contract = Mock(
party=party,
incoterm=incoterm,
from_location=from_location,
to_location=to_location,
plan=plan,
)
line = Mock(product=product, purchase=contract, sale=None)
rule = fee_module.FeeRule()
rule.apply_on = 'purchase_line'
rule.party = party
rule.line_product = product
rule.incoterm_code = 'FOB'
incoterm.code = 'FOB'
rule.from_location = from_location
rule.to_location = to_location
rule.execution_plan = plan
self.assertTrue(rule._matches_line(line, 'purchase_line'))
def test_fee_rule_matches_counterparty_category_parent(self):
'fee rule can match a counterparty by parent category'
parent_category = Mock(id=1, parent=None)
child_category = Mock(id=2, parent=parent_category)
party = Mock(id=3, categories=[child_category])
contract = Mock(
party=party,
incoterm=None,
from_location=None,
to_location=None,
plan=None,
)
line = Mock(product=None, purchase=contract, sale=None)
rule = fee_module.FeeRule()
rule.apply_on = 'purchase_line'
rule.party = None
rule.party_category = parent_category
rule.line_product = None
rule.incoterm_code = None
rule.from_location = None
rule.to_location = None
rule.execution_plan = None
self.assertTrue(rule._matches_line(line, 'purchase_line'))
def test_fee_rule_builds_budgeted_sale_fee_values(self):
'fee rule builds budgeted fee values for a sale line'
currency = Mock(id=1)
unit = Mock(id=2)
party = Mock(id=3)
product = Mock(id=4)
fee_product = Mock(id=5)
sale = Mock(party=party, currency=currency)
line = Mock(
id=10,
product=product,
sale=sale,
purchase=None,
unit=unit,
quantity_theorical=Decimal('100'),
quantity=Decimal('90'),
)
rule = fee_module.FeeRule()
rule.id = 20
rule.fee_product = fee_product
rule.supplier = None
rule.p_r = 'pay'
rule.mode = 'perqt'
rule.price = Decimal('12.50')
rule.quantity = None
rule.currency = None
rule.unit = None
rule.qt_state = None
rule.weight_type = 'brut'
values = rule._fee_values(line, 'sale_line')
self.assertEqual(values['source_rule'], 20)
self.assertTrue(values['generated_by_rule'])
self.assertEqual(values['type'], 'budgeted')
self.assertEqual(values['sale_line'], 10)
self.assertEqual(values['product'], 5)
self.assertEqual(values['supplier'], 3)
self.assertEqual(values['currency'], 1)
self.assertEqual(values['unit'], 2)
self.assertEqual(values['quantity'], Decimal('100'))
def test_sale_line_basis_price_and_progress_use_manual_summary_without_component(self):
'sale line basis values use manual summary rows even without a component'
SaleLine = Pool().get('sale.line')
summary_model = Mock()
summary_model.search.side_effect = [
[Mock(price=Decimal('150'), progress=1, price_component=None)],
[Mock(price=Decimal('150'), progress=1, price_component=None)],
]
line = SaleLine()
line.id = 1
line.price_type = 'basis'
line.price_components = []
line.enable_linked_currency = False
line.linked_currency = None
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = summary_model
self.assertEqual(line.get_basis_price(), Decimal('150.0000'))
self.assertEqual(line.get_progress('progress'), 1)
def test_purchase_line_basis_price_and_progress_use_manual_summary_without_component(self):
'purchase line basis values use manual summary rows even without a component'
PurchaseLine = Pool().get('purchase.line')
summary_model = Mock()
summary_model.search.side_effect = [
[Mock(price=Decimal('150'), progress=1, price_component=None)],
[Mock(price=Decimal('150'), progress=1, price_component=None)],
]
line = PurchaseLine()
line.id = 1
line.price_type = 'basis'
line.price_components = []
line.enable_linked_currency = False
line.linked_currency = None
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
PoolMock.return_value.get.return_value = summary_model
self.assertEqual(line.get_basis_price(), Decimal('150.0000'))
self.assertEqual(line.get_progress('progress'), 1)
def test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases(self):
'sale line write increases virtual lot and open lot.qt when contractual qty grows'
SaleLine = Pool().get('sale.line')
line = Mock(id=1, quantity_theorical=Decimal('10'))
line.quantity = Decimal('10')
line.unit = Mock()
line.fees = []
vlot = Mock(id=99, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10')
line.lots = [vlot]
lqt = Mock(lot_quantity=Decimal('10'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], []]
with patch(
'trytond.modules.purchase_trade.sale.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.sale.super'
) as super_mock, patch.object(SaleLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('12.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
SaleLine.write([line], {'quantity_theorical': Decimal('12')})
self.assertEqual(lqt.lot_quantity, Decimal('12'))
self.assertEqual(line.quantity, Decimal('12.00000'))
vlot.set_current_quantity.assert_called_once_with(
Decimal('12'), Decimal('12'), 1)
lot_model.save.assert_called()
lotqt_model.save.assert_called()
save.assert_not_called()
def test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity(self):
'sale line write blocks contractual qty decrease when open lot.qt is insufficient'
SaleLine = Pool().get('sale.line')
line = Mock(id=2, quantity_theorical=Decimal('10'))
line.quantity = Decimal('10')
line.fees = []
vlot = Mock(id=100, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10')
line.lots = [vlot]
lqt = Mock(lot_quantity=Decimal('1'))
matched_lqt = Mock(lot_quantity=Decimal('9'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], [matched_lqt]]
with patch(
'trytond.modules.purchase_trade.sale.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.sale.super'
) as super_mock, patch.object(SaleLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('8.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
with self.assertRaises(UserError):
SaleLine.write([line], {'quantity_theorical': Decimal('8')})
save.assert_not_called()
def test_purchase_line_write_syncs_open_lot_qt_with_physical_lots(self):
'purchase line write keeps open lot.qt net of existing physical lots'
PurchaseLine = Pool().get('purchase.line')
line = Mock(id=4, quantity_theorical=Decimal('10000'))
line.quantity = Decimal('10000')
line.unit = Mock()
line.fees = []
vlot = Mock(id=102, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10000')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('10000')
line.lots = [vlot, physical]
lqt = Mock(lot_quantity=Decimal('10000'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], []]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
for record in records:
record.quantity_theorical = values['quantity_theorical']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('20000')})
self.assertEqual(lqt.lot_quantity, Decimal('10000'))
self.assertEqual(line.quantity, Decimal('10000'))
vlot.set_current_quantity.assert_not_called()
lot_model.save.assert_not_called()
lotqt_model.save.assert_not_called()
save.assert_not_called()
def test_purchase_line_write_syncs_quantity_without_physical_lots(self):
'purchase line write keeps quantity equal to contractual qty without physical lots'
PurchaseLine = Pool().get('purchase.line')
line = Mock(id=6, quantity_theorical=Decimal('1500'))
line.quantity = Decimal('1500')
line.unit = Mock()
line.fees = []
vlot = Mock(id=104, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('1500')
line.lots = [vlot]
free_lqt = Mock(lot_quantity=Decimal('50'))
allocated_lqts = [
Mock(lot_quantity=Decimal('400')),
Mock(lot_quantity=Decimal('500')),
Mock(lot_quantity=Decimal('550')),
]
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[free_lqt], allocated_lqts]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('2000.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('2000')})
self.assertEqual(line.quantity, Decimal('2000.00000'))
self.assertEqual(free_lqt.lot_quantity, Decimal('550.00000'))
vlot.set_current_quantity.assert_called_once_with(
Decimal('2000.00000'), Decimal('2000.00000'), 1)
save.assert_not_called()
def test_purchase_line_write_syncs_virtual_fee_quantity(self):
'purchase line write updates fee quantity when only virtual lot exists'
PurchaseLine = Pool().get('purchase.line')
fee = Mock()
line = Mock(id=5, quantity_theorical=Decimal('20000'))
line.quantity = Decimal('20000')
line.unit = Mock()
line.fees = [fee]
vlot = Mock(id=103, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('20000')
line.lots = [vlot]
lqt = Mock(lot_quantity=Decimal('20000'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], []]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('10000.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('10000')})
self.assertEqual(lqt.lot_quantity, Decimal('10000.00000'))
fee.sync_quantity_from_lots.assert_called_once_with()
fee.adjust_purchase_values.assert_called_once_with()
save.assert_not_called()
def test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot(self):
'purchase line write does not re-add quantity when initializing contractual qty'
PurchaseLine = Pool().get('purchase.line')
line = Mock(id=3, quantity_theorical=None)
line.quantity = Decimal('10')
vlot = Mock(id=101, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10')
line.lots = [vlot]
lot_model = Mock()
lotqt_model = Mock()
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('10')})
vlot.set_current_quantity.assert_not_called()
lot_model.save.assert_not_called()
lotqt_model.save.assert_not_called()
save.assert_not_called()
def test_party_execution_achieved_percent_uses_real_area_statistics(self):
'party execution achieved percent reflects the controller share in its area'
PartyExecution = Pool().get('party.execution')
execution = PartyExecution()
execution.party = Mock(id=1)
execution.area = Mock(id=10)
shipments = [
Mock(controller=Mock(id=1)),
Mock(controller=Mock(id=2)),
Mock(controller=Mock(id=1)),
Mock(controller=Mock(id=2)),
Mock(controller=Mock(id=1)),
]
shipment_model = Mock()
shipment_model.search.return_value = shipments
with patch(
'trytond.modules.purchase_trade.party.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = shipment_model
self.assertEqual(
execution.get_percent('achieved_percent'),
Decimal('60.00'))
def test_get_controller_prioritizes_controller_farthest_from_target(self):
'shipment controller selection prioritizes the most under-target rule'
Shipment = Pool().get('stock.shipment.in')
Party = Pool().get('party.party')
PartyExecution = Pool().get('party.execution')
shipment = Shipment()
shipment.to_location = Mock(
country=Mock(region=Mock(id=20, parent=Mock(id=10, parent=None))))
party_a = Party()
party_a.id = 1
rule_a = PartyExecution()
rule_a.party = party_a
rule_a.area = Mock(id=10)
rule_a.percent = Decimal('80')
rule_a.compute_achieved_percent = Mock(return_value=Decimal('40'))
party_a.execution = [rule_a]
party_b = Party()
party_b.id = 2
rule_b = PartyExecution()
rule_b.party = party_b
rule_b.area = Mock(id=10)
rule_b.percent = Decimal('50')
rule_b.compute_achieved_percent = Mock(return_value=Decimal('45'))
party_b.execution = [rule_b]
category_model = Mock()
category_model.search.return_value = [Mock(id=99)]
party_category_model = Mock()
party_category_model.search.return_value = [
Mock(party=party_b),
Mock(party=party_a),
]
with patch(
'trytond.modules.purchase_trade.stock.Pool'
) as PoolMock:
def get_model(name):
return {
'party.category': category_model,
'party.party-party.category': party_category_model,
}[name]
PoolMock.return_value.get.side_effect = get_model
self.assertIs(shipment.get_controller(), party_a)
def test_weight_report_get_source_shipment_rejects_multiple_shipments(self):
'weight report export must not guess when the same WR is linked twice'
WeightReport = Pool().get('weight.report')
report = WeightReport()
report.id = 7
shipment_wr_model = Mock()
shipment_wr_model.search.return_value = [
Mock(shipment_in=Mock(id=1)),
Mock(shipment_in=Mock(id=2)),
]
with patch(
'trytond.modules.purchase_trade.weight_report.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = shipment_wr_model
with self.assertRaises(UserError):
report.get_source_shipment()
def test_weight_report_remote_context_requires_controller_and_returned_id(self):
'weight report export checks the shipment prerequisites before calling FastAPI'
WeightReport = Pool().get('weight.report')
report = WeightReport()
report.bales = 100
report.report_date = Mock(strftime=Mock(return_value='20260406'))
report.weight_date = Mock(strftime=Mock(return_value='20260406'))
shipment = Mock(
controller=None,
returned_id='RET-001',
agent=Mock(),
to_location=Mock(),
)
with self.assertRaises(UserError):
report.validate_remote_weight_report_context(shipment)
shipment.controller = Mock()
shipment.returned_id = None
with self.assertRaises(UserError):
report.validate_remote_weight_report_context(shipment)
def test_weight_report_create_from_json_accepts_missing_weight_date(self):
'weight report import keeps working when OCR returns null for weight_date'
WeightReport = Pool().get('weight.report')
party_model = Mock()
vessel_model = Mock()
location_model = Mock()
party_model.getPartyByName.side_effect = lambda name: Mock(id={
'SELLER': 1,
'BUYER': 2,
'CARRIER': 3,
}[name])
vessel_model.search.return_value = []
location_model.search.return_value = []
payload = {
'lab': 'LAB',
'report': {
'reference': 'REF',
'file_no': 'FILE',
'date': '28 October 2025',
},
'contract': {},
'parties': {
'seller': 'SELLER',
'buyer': 'BUYER',
'carrier': 'CARRIER',
},
'shipment': {
'bl_no': 'BL-1',
'weighing_place': 'PORT',
'weighing_method': 'METHOD',
'bales': 10,
},
'weights': {
'weight_date': None,
'gross_landed_kg': '10',
'tare_kg': '1',
'net_landed_kg': '9',
'invoice_net_kg': '9',
'gain_loss_kg': '0',
'gain_loss_percent': '0',
}
}
with patch(
'trytond.modules.purchase_trade.weight_report.Pool') as PoolMock, patch.object(
WeightReport, 'create', return_value=[payload]) as create_mock:
PoolMock.return_value.get.side_effect = lambda name: {
'party.party': party_model,
'trade.vessel': vessel_model,
'stock.location': location_model,
}[name]
created = WeightReport.create_from_json(payload)
self.assertEqual(created, payload)
values = create_mock.call_args.args[0][0]
self.assertIsNone(values['weight_date'])
self.assertEqual(values['report_date'].isoformat(), '2025-10-28')
def test_weight_report_create_from_json_accepts_bales_with_thousand_separator(self):
'weight report import accepts OCR bale counts formatted like 1.100'
WeightReport = Pool().get('weight.report')
party_model = Mock()
vessel_model = Mock()
location_model = Mock()
party_model.getPartyByName.side_effect = lambda name: Mock(id={
'SELLER': 1,
'BUYER': 2,
'CARRIER': 3,
}[name])
vessel_model.search.return_value = []
location_model.search.return_value = []
payload = {
'lab': 'LAB',
'report': {
'reference': 'REF',
'file_no': 'FILE',
'date': '02 Apr 2026',
},
'contract': {},
'parties': {
'seller': 'SELLER',
'buyer': 'BUYER',
'carrier': 'CARRIER',
},
'shipment': {
'bl_no': 'BL-1',
'weighing_place': 'PORT',
'weighing_method': 'METHOD',
'bales': '1.100',
},
'weights': {
'weight_date': None,
'gross_landed_kg': '10',
'tare_kg': '1',
'net_landed_kg': '9',
'invoice_net_kg': '9',
'gain_loss_kg': '0',
'gain_loss_percent': '0',
}
}
with patch(
'trytond.modules.purchase_trade.weight_report.Pool') as PoolMock, patch.object(
WeightReport, 'create', return_value=[payload]) as create_mock:
PoolMock.return_value.get.side_effect = lambda name: {
'party.party': party_model,
'trade.vessel': vessel_model,
'stock.location': location_model,
}[name]
created = WeightReport.create_from_json(payload)
self.assertEqual(created, payload)
values = create_mock.call_args.args[0][0]
self.assertEqual(values['bales'], 1100)
def test_invoice_report_uses_invoice_template_from_configuration(self):
'invoice report path is resolved from purchase_trade configuration'
report_class = Pool().get('account.invoice', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
sale_report_template='sale_melya.fodt',
sale_commission_report_template='sale_commission_ict.fodt',
sale_bill_report_template='bill_melya.fodt',
sale_final_report_template='sale_final_melya.fodt',
invoice_report_template='invoice_melya.fodt',
invoice_cndn_report_template='invoice_ict_final.fodt',
invoice_commission_cndn_report_template=(
'commission__ict_final.fodt'),
invoice_prepayment_report_template='prepayment.fodt',
invoice_packing_list_report_template='packing_list.fodt',
invoice_payment_order_report_template='payment_order.fodt',
purchase_report_template='purchase_melya.fodt',
purchase_commission_report_template=(
'purchase_commission_ict.fodt'),
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Invoice',
'report': 'account_invoice/invoice.fodt',
}),
'account_invoice/invoice_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Prepayment',
'report': 'account_invoice/prepayment.fodt',
}),
'account_invoice/prepayment.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'CN/DN',
'report': 'account_invoice/invoice_ict_final.fodt',
}),
'account_invoice/invoice_ict_final.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'CN/DN Commission',
'report': 'account_invoice/commission__ict_final.fodt',
}),
'account_invoice/commission__ict_final.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Payment Order',
'report': 'account_invoice/payment_order.fodt',
}),
'account_invoice/payment_order.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Packing List',
'report': 'account_invoice/packing_list.fodt',
}),
'account_invoice/packing_list.fodt')
def test_invoice_report_raises_when_template_is_missing(self):
'invoice report must fail clearly when no template is configured'
report_class = Pool().get('account.invoice', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
invoice_report_template='',
invoice_cndn_report_template='',
invoice_commission_cndn_report_template='',
invoice_prepayment_report_template='',
invoice_packing_list_report_template='',
invoice_payment_order_report_template='',
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
with self.assertRaises(UserError):
report_class._resolve_configured_report_path({
'name': 'Invoice',
'report': 'account_invoice/invoice.fodt',
})
with self.assertRaises(UserError):
report_class._resolve_configured_report_path({
'name': 'Payment Order',
'report': 'account_invoice/payment_order.fodt',
})
with self.assertRaises(UserError):
report_class._resolve_configured_report_path({
'name': 'Packing List',
'report': 'account_invoice/packing_list.fodt',
})
def test_sale_report_uses_templates_from_configuration(self):
'sale report paths are resolved from purchase_trade configuration'
report_class = Pool().get('sale.sale', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
sale_report_template='sale_melya.fodt',
sale_commission_report_template='sale_commission_ict.fodt',
sale_bill_report_template='bill_melya.fodt',
sale_final_report_template='sale_final_melya.fodt',
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Sale',
'report': 'sale/sale.fodt',
}),
'sale/sale_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Commission invoice',
'report': 'sale/sale_commission_ict.fodt',
}),
'sale/sale_commission_ict.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Bill',
'report': 'sale/bill.fodt',
}),
'sale/bill_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Sale (final)',
'report': 'sale/sale_final.fodt',
}),
'sale/sale_final_melya.fodt')
def test_purchase_report_uses_template_from_configuration(self):
'purchase report path is resolved from purchase_trade configuration'
report_class = Pool().get('purchase.purchase', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
purchase_report_template='purchase_melya.fodt',
purchase_commission_report_template=(
'purchase_commission_ict.fodt'),
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Purchase',
'report': 'purchase/purchase.fodt',
}),
'purchase/purchase_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Commission invoice',
'report': 'purchase/purchase_commission_ict.fodt',
}),
'purchase/purchase_commission_ict.fodt')
def test_shipment_reports_use_templates_from_configuration(self):
'shipment report paths are resolved from purchase_trade configuration'
shipping_report = Pool().get('stock.shipment.in.shipping', type='report')
insurance_report = Pool().get('stock.shipment.in.insurance', type='report')
coo_report = Pool().get('stock.shipment.in.coo', type='report')
packing_report = Pool().get('stock.shipment.in.packing_list', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
shipment_shipping_report_template='si_custom.fodt',
shipment_insurance_report_template='insurance_custom.fodt',
shipment_coo_report_template='coo_custom.fodt',
shipment_packing_list_report_template='packing_list_custom.fodt',
)
]
with patch(
'trytond.modules.purchase_trade.stock.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
shipping_report._resolve_configured_report_path({
'name': 'Shipping instructions',
'report': 'stock/si.fodt',
}),
'stock/si_custom.fodt')
self.assertEqual(
insurance_report._resolve_configured_report_path({
'name': 'Insurance',
'report': 'stock/insurance.fodt',
}),
'stock/insurance_custom.fodt')
self.assertEqual(
coo_report._resolve_configured_report_path({
'name': 'COO',
'report': 'stock/coo.fodt',
}),
'stock/coo_custom.fodt')
self.assertEqual(
packing_report._resolve_configured_report_path({
'name': 'Packing List',
'report': 'stock/packing_list.fodt',
}),
'stock/packing_list_custom.fodt')
def test_configuration_syncs_report_menu_labels(self):
'document template configuration updates report menu labels'
Configuration = Pool().get('purchase_trade.configuration')
config = Mock(
sale_report_label='Offer',
sale_bill_report_label='',
invoice_report_label='',
invoice_cndn_report_label='',
invoice_prepayment_report_label='',
invoice_packing_list_report_label='Packing Slip',
invoice_payment_order_report_label='Wire Order',
purchase_report_label='',
shipment_shipping_report_label='',
shipment_insurance_report_label='',
shipment_coo_report_label='Certificate of Origin',
shipment_packing_list_report_label='',
)
action_sale = Mock(spec=['name'])
action_sale.name = 'Proforma'
action_bill = Mock(spec=['name'])
action_bill.name = 'Old Draft'
action_invoice = Mock(spec=['name'])
action_invoice.name = 'Invoice'
action_cndn = Mock(spec=['name'])
action_cndn.name = 'CN/DN'
action_prepayment = Mock(spec=['name'])
action_prepayment.name = 'Prepayment'
action_payment_order = Mock(spec=['name'])
action_payment_order.name = 'Payment Order'
action_invoice_packing = Mock(spec=['name'])
action_invoice_packing.name = 'Packing List'
action_purchase = Mock(spec=['name'])
action_purchase.name = 'Purchase'
action_shipping = Mock(spec=['name'])
action_shipping.name = 'Shipping instructions'
action_insurance = Mock(spec=['name'])
action_insurance.name = 'Insurance'
action_coo = Mock(spec=['name'])
action_coo.name = 'COO'
action_packing = Mock(spec=['name'])
action_packing.name = 'Packing List'
actions = {
1: action_sale,
2: action_bill,
3: action_invoice,
4: action_cndn,
5: action_prepayment,
6: action_payment_order,
7: action_invoice_packing,
8: action_purchase,
9: action_shipping,
10: action_insurance,
11: action_coo,
12: action_packing,
}
model_data = Mock()
model_data.get_id.side_effect = list(actions.keys())
action_report = Mock()
action_report.side_effect = actions.__getitem__
def get_model(name):
return {
'ir.model.data': model_data,
'ir.action.report': action_report,
}[name]
with patch(
'trytond.modules.purchase_trade.configuration.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = get_model
Configuration._sync_report_labels([config])
self.assertEqual(
action_report.write.call_args.args,
(
[actions[1]], {'name': 'Offer'},
[actions[2]], {'name': 'Draft'},
[actions[7]], {'name': 'Packing Slip'},
[actions[6]], {'name': 'Wire Order'},
[actions[11]], {'name': 'Certificate of Origin'},
))
def test_shipment_insurance_helpers_use_fee_and_controller(self):
'shipment insurance helpers read insurance fee and shipment context'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.number = 'IN/0001'
shipment.bl_number = 'BL-001'
shipment.from_location = Mock(name='LIVERPOOL')
shipment.to_location = Mock(name='LE HAVRE')
shipment.vessel = Mock(vessel_name='MV ATLANTIC')
shipment.controller = Mock(rec_name='CONTROL UNION')
shipment.supplier = Mock(rec_name='MELYA SA')
sale_party = Mock(rec_name='SGT FR')
sale = Mock(
id=1,
party=sale_party,
report_melya_proforma_number='P-12 S-34',
)
product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON')
line = Mock(product=product, sale=sale)
lot = Mock(sale_line=line, line=None)
move = Mock(lot=lot, product=product)
shipment.incoming_moves = [move]
shipment.moves = [move]
insurance_fee = Mock()
insurance_fee.product = Mock(name='Insurance')
insurance_fee.currency = Mock(rec_name='USD')
insurance_fee.get_amount.return_value = Decimal('1234.56')
insurance_fee.supplier = Mock(rec_name='HELVETIA')
shipment.fees = [insurance_fee]
with patch(
'trytond.modules.purchase_trade.stock.Pool'
) as PoolMock:
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 4, 6)
config_model = Mock()
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': date_model,
'purchase_trade.configuration': config_model,
}[name]
shipment.company = Mock(
party=Mock(
rec_name='MELYA SA',
address_get=Mock(return_value=Mock(city='GENEVA')),
)
)
self.assertEqual(
shipment.report_insurance_certificate_number, 'BL-001')
self.assertEqual(
shipment.report_insurance_account_of, 'SGT FR')
self.assertEqual(
shipment.report_insurance_goods_description,
'COTTON UPLAND - RAW WHITE COTTON')
self.assertEqual(
shipment.report_insurance_order_reference, 'P-12 S-34')
self.assertEqual(
shipment.report_insurance_amount, 'USD 1234.56')
self.assertEqual(
shipment.report_insurance_surveyor, 'CONTROL UNION')
self.assertEqual(
shipment.report_insurance_issue_place_and_date,
'GENEVA, 06-04-2026')
def test_shipment_insurance_amount_fallback_uses_lot_and_incoming_moves(self):
'insurance amount falls back to lot unit price and shipment quantities'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
purchase_currency = Mock(rec_name='USD')
purchase = Mock(currency=purchase_currency)
line = Mock(unit_price=Decimal('100'), purchase=purchase)
lot = Mock(line=line)
lot.get_current_quantity_converted = Mock(return_value=Decimal('5'))
move = Mock(quantity=Decimal('0'), unit_price=None, currency=None, lot=lot)
shipment.incoming_moves = [move]
shipment.fees = []
self.assertEqual(
shipment.report_insurance_incoming_amount, 'USD 500.00')
self.assertEqual(
shipment.report_insurance_amount_insured, 'USD 550.00')
self.assertEqual(
shipment.report_insurance_amount, 'USD 550.00')
def test_shipment_insurance_contact_surveyor_prefers_shipment_surveyor(self):
'insurance contact surveyor property uses shipment surveyor first'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.surveyor = Mock(rec_name='SGS')
shipment.controller = Mock(rec_name='CONTROL UNION')
shipment.fees = []
self.assertEqual(
shipment.report_insurance_contact_surveyor, 'SGS')
self.assertEqual(
shipment.report_insurance_surveyor, 'SGS')
def test_shipment_insurance_order_reference_falls_back_to_lot_qt(self):
'insurance order reference can read sale deal through matching lot.qt'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
sale = Mock(
id=1,
report_melya_proforma_number='',
report_deal='P-12 S-34',
)
sale_lot = Mock(sale_line=Mock(sale=sale))
shipment.incoming_moves = []
shipment.moves = []
shipment.lotqt = [Mock(lot_s=sale_lot)]
self.assertEqual(
shipment.report_insurance_order_reference, 'P-12 S-34')
def test_shipment_packing_helpers_use_today_and_trade_unit(self):
'packing list helpers expose today date and trade line unit'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.quantity = Decimal('1010')
shipment.unit = Mock(symbol='KGS', rec_name='KGS')
trade_line = Mock()
trade_line.unit = Mock(symbol='MT', rec_name='MT')
lot = Mock(line=trade_line, sale_line=None)
lot.get_current_quantity = Mock(return_value=Decimal('1010'))
lot.get_current_gross_quantity = Mock(return_value=Decimal('1012'))
move = Mock(lot=lot, quantity=Decimal('0'))
shipment.incoming_moves = [move]
shipment.moves = [move]
with patch(
'trytond.modules.purchase_trade.stock.Pool'
) as PoolMock:
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 4, 8)
PoolMock.return_value.get.return_value = date_model
self.assertEqual(
shipment.report_packing_today_date, 'April 8, 2026')
self.assertEqual(
shipment.report_packing_weight_unit, 'MT')
def test_sale_report_multi_line_helpers_aggregate_all_lines(self):
'sale report helpers aggregate quantity, price lines and shipment periods'
Sale = Pool().get('sale.sale')
def make_line(quantity, period, linked_price):
line = Mock()
line.type = 'line'
line.quantity = quantity
line.note = ''
line.price_type = 'priced'
line.unit_price = Decimal('0')
line.linked_price = Decimal(linked_price)
line.linked_currency = Mock(rec_name='USC')
line.linked_unit = Mock(rec_name='POUND')
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description=period)
line.get_pricing_text = f'ON ICE Cotton #2 {period}'
line.lots = []
return line
sale = Sale()
sale.currency = Mock(rec_name='USD')
sale.lines = [
make_line('1000', 'MARCH 2026', '72.5000'),
make_line('1000', 'MAY 2026', '70.2500'),
]
self.assertEqual(sale.report_total_quantity, '2000.0')
self.assertEqual(sale.report_quantity_unit_upper, 'MT')
self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS')
self.assertEqual(sale.report_nb_bale, '')
self.assertEqual(
sale.report_quantity_lines.splitlines(),
[
'1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026',
'1000.0 MT (ONE THOUSAND METRIC TONS) - MAY 2026',
])
self.assertEqual(
sale.report_shipment_periods.splitlines(),
['MARCH 2026', 'MAY 2026'])
self.assertEqual(
sale.report_price_lines.splitlines(),
[
'USC 72.5000 PER POUND (SEVENTY TWO USC AND FIFTY CENTS) ON ICE Cotton #2 MARCH 2026',
'USC 70.2500 PER POUND (SEVENTY USC AND TWENTY FIVE CENTS) ON ICE Cotton #2 MAY 2026',
])
def test_sale_report_price_composition_lines_uses_composition(self):
'sale report exposes price composition lines for invoice_melya'
Sale = Pool().get('sale.sale')
line = Mock()
line.type = 'line'
line.price_type = 'priced'
line.linked_currency = None
line.linked_unit = None
line.unit = Mock(rec_name='MT')
line.unit_price = Decimal('2525')
line.amount = Decimal('62493.75')
line.price_composition = [
Mock(component='FOB', price=Decimal('2500')),
Mock(component='FREIGHT', price=None),
]
sale = Sale()
sale.currency = Mock(rec_name='EUR')
sale.lines = [line]
self.assertEqual(
sale.report_price_composition_lines.splitlines(),
[
'FOB value in EUR',
'FREIGHT value in EUR',
])
self.assertEqual(
sale.report_price_composition_rows,
[
{
'label': 'FOB value in EUR',
'amount': Decimal('2500'),
'amount_text': '2,500.00',
},
{
'label': 'FREIGHT value in EUR',
'amount': Decimal('59993.75'),
'amount_text': '59,993.75',
},
])
def test_invoice_report_rate_lines_prefers_sale_price_composition(self):
'invoice_melya rate block uses sale price composition when available'
Invoice = Pool().get('account.invoice')
sale = Mock(
report_price_composition_rows=[
{'label': 'FOB value in EUR', 'amount': Decimal('2500')},
{'label': 'FREIGHT value in EUR', 'amount': Decimal('25')},
])
invoice = Invoice()
invoice.sales = [sale]
self.assertEqual(
invoice.report_rate_lines.splitlines(),
[
'FOB value in EUR',
'FREIGHT value in EUR',
])
self.assertEqual(
invoice.report_rate_rows,
[
{'label': 'FOB value in EUR', 'amount': Decimal('2500')},
{'label': 'FREIGHT value in EUR', 'amount': Decimal('25')},
])
def test_invoice_report_melya_rate_rows_has_no_price_fallback(self):
'invoice_melya rate block stays empty without price composition'
Invoice = Pool().get('account.invoice')
line = Mock(
type='line',
report_rate_currency_upper='USD',
report_rate_value=Decimal('2050'),
report_rate_unit_upper='MT',
report_rate_price_words='TWO THOUSAND FIFTY DOLLARS',
report_rate_pricing_text='',
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_melya_rate_rows, [])
self.assertEqual(
invoice.report_rate_rows,
[{
'label': 'USD 2050.0000 PER MT '
'(TWO THOUSAND FIFTY DOLLARS)',
'amount': None,
}])
def test_fee_delete_detects_generated_ordered_purchase(self):
'ordered fee deletion targets only its generated service purchase'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 10
fee.type = 'ordered'
purchase = Mock(
id=20,
line_type='service',
lines=[Mock(fee_=fee)])
fee.purchase = purchase
self.assertEqual(
Fee._get_generated_purchases_to_delete([fee]),
[purchase])
def test_fee_delete_ignores_unrelated_purchase(self):
'ordered fee deletion does not delete purchases with unrelated lines'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 10
fee.type = 'ordered'
purchase = Mock(
id=20,
line_type='service',
lines=[Mock(fee_=Mock(id=99))])
fee.purchase = purchase
self.assertEqual(Fee._get_generated_purchases_to_delete([fee]), [])
def test_sale_report_converts_mixed_units_for_total_and_words(self):
'sale report totals prefer the virtual lot unit as common unit'
Sale = Pool().get('sale.sale')
mt = Mock(id=1, rec_name='MT')
kg = Mock(id=2, rec_name='KILOGRAM')
line_mt = Mock()
line_mt.type = 'line'
line_mt.quantity = Decimal('1000')
line_mt.unit = mt
line_mt.del_period = Mock(description='MARCH 2026')
line_mt.lots = []
virtual = Mock(lot_type='virtual', lot_unit_line=kg)
virtual.get_hist_quantity.return_value = (
Decimal('1000000'),
Decimal('1000000'),
)
line_kg = Mock()
line_kg.type = 'line'
line_kg.quantity = Decimal('1000')
line_kg.unit = mt
line_kg.del_period = Mock(description='MAY 2026')
line_kg.lots = [virtual]
sale = Sale()
sale.lines = [line_mt, line_kg]
uom_model = Mock()
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: (
qty * 1000
if getattr(from_unit, 'rec_name', None) == 'MT'
and getattr(to_unit, 'rec_name', None) == 'KILOGRAM'
else (
qty / 1000
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
and getattr(to_unit, 'rec_name', None) == 'MT'
else qty
)
))
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(sale.report_total_quantity, '2000000.0')
self.assertEqual(sale.report_quantity_unit_upper, 'KILOGRAM')
self.assertEqual(sale.report_qt, 'TWO MILLION KILOGRAMS')
self.assertEqual(
sale.report_quantity_lines.splitlines(),
[
'1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026',
'1000000.0 KILOGRAM (ONE MILLION KILOGRAMS) - MAY 2026',
])
def test_sale_report_total_unit_falls_back_when_multiple_virtual_lots(self):
'sale report common unit uses virtual only when there is a single one'
Sale = Pool().get('sale.sale')
mt = Mock(id=1, rec_name='MT')
kg = Mock(id=2, rec_name='KILOGRAM')
line_mt = Mock(type='line', quantity=Decimal('1000'), unit=mt)
line_mt.del_period = Mock(description='MARCH 2026')
line_mt.lots = []
virtual_a = Mock(lot_type='virtual', lot_unit_line=kg)
virtual_a.get_hist_quantity.return_value = (
Decimal('1000000'),
Decimal('1000000'),
)
virtual_b = Mock(lot_type='virtual', lot_unit_line=kg)
virtual_b.get_hist_quantity.return_value = (
Decimal('1000000'),
Decimal('1000000'),
)
line_kg = Mock(type='line', quantity=Decimal('1000'), unit=mt)
line_kg.del_period = Mock(description='MAY 2026')
line_kg.lots = [virtual_a, virtual_b]
sale = Sale()
sale.lines = [line_mt, line_kg]
uom_model = Mock()
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: (
qty / 1000
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
and getattr(to_unit, 'rec_name', None) == 'MT'
else qty
))
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(sale.report_quantity_unit_upper, 'MT')
self.assertEqual(sale.report_total_quantity, '2000.0')
self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS')
def test_report_product_fields_expose_name_and_description(self):
'sale and invoice templates use stable product name/description helpers'
Sale = Pool().get('sale.sale')
Invoice = Pool().get('account.invoice')
product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON')
sale_line = Mock(product=product)
invoice_line = Mock(product=product)
sale = Sale()
sale.lines = [sale_line]
invoice = Invoice()
invoice.lines = [invoice_line]
invoice._get_report_trade_line = Mock(return_value=sale_line)
self.assertEqual(sale.report_product_name, 'COTTON UPLAND')
self.assertEqual(sale.report_product_description, 'RAW WHITE COTTON')
self.assertEqual(invoice.report_product_name, 'COTTON UPLAND')
self.assertEqual(invoice.report_product_description, 'RAW WHITE COTTON')
def test_invoice_melya_reference_uses_sale_deal_number(self):
'invoice_melya Reference uses the sale deal number'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
sale = Mock(report_deal='P-12 S-34')
invoice._get_report_deal_sale = Mock(return_value=sale)
self.assertEqual(invoice.report_contract_number, 'P-12/S-34')
def test_invoice_melya_bank_uses_sale_our_bank_account(self):
'invoice_melya bank block uses the sale company bank account'
Invoice = Pool().get('account.invoice')
address = Mock(
party_full_name='UBS SWITZERLAND AG',
name='Case Postale',
street='',
postal_code='CH-1211 Geneve 2',
city='',
full_address='UBS SWITZERLAND AG\nCase Postale\n'
'CH-1211 Geneve 2',
)
party = Mock(rec_name='UBS')
party.address_get.return_value = address
bank = Mock(party=party, bic='UBSWCHZH80A')
account = Mock(
bank=bank,
numbers=[Mock(type='iban', number='CH36 0021 5215 2487 7461 D')],
)
sale = Mock(our_bank_account=account)
invoice = Invoice()
invoice._get_report_deal_sale = Mock(return_value=sale)
self.assertEqual(invoice.report_melya_bank_name, 'UBS SWITZERLAND AG')
self.assertEqual(
invoice.report_melya_bank_address,
'Case Postale\nCH-1211 Geneve 2')
self.assertEqual(
invoice.report_melya_bank_iban,
'CH36 0021 5215 2487 7461 D')
self.assertEqual(invoice.report_melya_bank_swift, 'UBSWCHZH80A')
self.assertEqual(
invoice.report_melya_bank_details_line,
'BANK DETAILS:\t\tUBS SWITZERLAND AG')
self.assertEqual(
invoice.report_melya_bank_account_line,
'IBAN : CH36 0021 5215 2487 7461 D, Swift Code: UBSWCHZH80A')
def test_sale_melya_bank_uses_our_bank_account(self):
'sale_melya bank block uses the sale company bank account'
Sale = Pool().get('sale.sale')
address = Mock(
party_full_name='UBS SWITZERLAND AG',
name='Case Postale',
street='',
postal_code='CH-1211 Geneve 2',
city='',
)
party = Mock(
rec_name='UBS SWITZERLAND AG',
address_get=Mock(return_value=address),
)
bank = Mock(party=party, bic='UBSWCHZH80A')
account = Mock(
bank=bank,
numbers=[Mock(type='iban', number='CH36 0021 5215 2487 7461 D')],
)
sale = Sale()
sale.our_bank_account = account
self.assertEqual(sale.report_melya_bank_name, 'UBS SWITZERLAND AG')
self.assertEqual(
sale.report_melya_bank_address,
'Case Postale CH-1211 Geneve 2')
self.assertEqual(
sale.report_melya_bank_iban,
'CH36 0021 5215 2487 7461 D')
self.assertEqual(sale.report_melya_bank_swift, 'UBSWCHZH80A')
self.assertEqual(
sale.report_melya_bank_swift_line,
'Swift Code: UBSWCHZH80A')
self.assertEqual(
sale.report_melya_bank_iban_line,
'IBAN : CH36 0021 5215 2487 7461 D')
self.assertEqual(
sale.report_melya_bank_account_line,
'IBAN : CH36 0021 5215 2487 7461 D, Swift Code: UBSWCHZH80A')
def test_invoice_melya_maturity_date_uses_lines_to_pay(self):
'invoice_melya maturity date uses the earliest line to pay maturity'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice.lines_to_pay = [
Mock(maturity_date=datetime.date(2026, 9, 9)),
Mock(maturity_date=datetime.date(2026, 8, 9)),
Mock(maturity_date=None),
]
self.assertEqual(
invoice.report_melya_maturity_date,
datetime.date(2026, 8, 9))
def test_sale_report_deal_uses_purchase_and_sale_numbers(self):
'sale deal number includes the linked purchase number'
Sale = Pool().get('sale.sale')
purchase = Mock(number='12')
lot = Mock(line=Mock(purchase=purchase))
sale = Sale()
sale.number = '88'
sale.lines = [Mock(lots=[lot])]
self.assertEqual(sale.report_deal, 'P-12 S-88')
self.assertEqual(sale.report_melya_proforma_number, 'P-12 S-88')
def test_sale_report_deal_falls_back_to_lot_qt_matching(self):
'sale deal number finds purchase through lot.qt matching'
Sale = Pool().get('sale.sale')
class SaleLot:
id = 99
line = None
purchase = Mock(number='12')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_p=Mock(line=Mock(purchase=purchase)))]
sale = Sale()
sale.number = '88'
sale.lines = [Mock(lots=[SaleLot()])]
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = lot_qt_model
self.assertEqual(sale.report_deal, 'P-12 S-88')
lot_qt_model.search.assert_called_once_with([
('lot_s', '=', 99),
('lot_p', '>', 0),
], limit=1)
def test_contract_factory_uses_one_line_per_selected_source(self):
'matched multi-lot contract creation keeps one generated line per source lot'
contract_detail = Mock(quantity=Decimal('2000'))
ct = Mock(matched=True)
line_a = Mock()
line_b = Mock()
sources = [
{'lot': Mock(), 'trade_line': line_a, 'quantity': Decimal('1000')},
{'lot': Mock(), 'trade_line': line_b, 'quantity': Decimal('1000')},
]
result = ContractFactory._get_line_sources(contract_detail, sources, ct)
self.assertEqual(len(result), 2)
self.assertEqual([r['quantity'] for r in result], [
Decimal('1000'), Decimal('1000')])
self.assertTrue(all(r['use_source_delivery'] for r in result))
def test_contract_factory_rejects_multi_lot_quantity_mismatch(self):
'matched multi-lot contract creation rejects totals that do not match the selection'
contract_detail = Mock(quantity=Decimal('1500'))
ct = Mock(matched=True)
sources = [
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')},
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')},
]
with self.assertRaises(UserError):
ContractFactory._get_line_sources(contract_detail, sources, ct)
def test_contract_factory_rejects_total_above_selected_open_quantity(self):
'matched create contracts cannot consume more than selected open quantity'
contracts = [
Mock(quantity=Decimal('100')),
Mock(quantity=Decimal('100')),
]
ct = Mock(matched=True)
sources = [
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('100')},
]
with self.assertRaises(UserError):
ContractFactory._validate_requested_quantity(contracts, sources, ct)
def test_contract_factory_uses_party_tolerance_when_line_is_empty(self):
'create contracts falls back to counterparty tolerance'
party = Mock(tol_min=Decimal('2'), tol_max=Decimal('5'))
contract_detail = Mock(party=party, tol_min=None, tol_max=None)
self.assertEqual(
ContractFactory._get_tolerance(contract_detail, 'tol_min'),
Decimal('2'))
self.assertEqual(
ContractFactory._get_tolerance(contract_detail, 'tol_max'),
Decimal('5'))
def test_contract_factory_uses_base_crop_when_line_is_empty(self):
'hidden crop falls back to the source contract crop'
crop = Mock()
contract_detail = Mock(crop=None)
base_contract = Mock(crop=crop)
self.assertEqual(
ContractFactory._get_crop(contract_detail, base_contract),
crop)
def test_contract_factory_locations_fallback_to_source_contract(self):
'create contracts keeps required locations when wizard line is empty'
source_from = Mock(type='supplier')
source_to = Mock(type='storage')
contract_detail = Mock(from_location=None, to_location=None)
base_contract = Mock(
from_location=source_from, to_location=source_to)
from_location, to_location = ContractFactory._get_locations(
contract_detail, base_contract, 'Sale')
self.assertEqual(from_location, source_to)
self.assertEqual(to_location, source_to)
def test_contract_factory_required_header_fields_fallback_to_defaults(self):
'create contracts fills required header fields outside the normal form'
contract = Mock()
party = Mock(wb=None, association=None, certif=None)
base_contract = Mock(wb=None, association=None, certif=None)
Contract = Mock()
Contract.default_wb.return_value = 1
Contract.default_association.return_value = 2
Contract.default_certif.return_value = 3
with patch('trytond.modules.purchase_trade.service.Pool') as PoolMock:
PoolMock.return_value.get.return_value = Contract
ContractFactory._apply_party_data(
contract, party, base_contract, 'Purchase')
self.assertEqual(contract.wb, 1)
self.assertEqual(contract.association, 2)
self.assertEqual(contract.certif, 3)
def test_contract_factory_incoterm_fallback_to_source_contract(self):
'create contracts keeps required incoterm when wizard line is empty'
incoterm = Mock()
contract_detail = Mock(incoterm=None)
base_contract = Mock(incoterm=incoterm)
self.assertEqual(
ContractFactory._get_contract_value(
contract_detail, base_contract, 'incoterm'),
incoterm)
def test_contract_factory_currency_unit_fallback_to_hidden_defaults(self):
'create contracts can price even when currency/unit selector is empty'
contract_detail = Mock(
currency_unit=None,
currency=Mock(id=7),
unit=Mock(id=9),
)
base_contract = Mock(currency=Mock(id=3))
self.assertEqual(
ContractFactory._get_currency_unit_parts(
contract_detail, base_contract),
['7', '9'])
def test_contract_factory_delivery_dates_fallback_to_period(self):
'hidden delivery dates are still derived from delivery period'
period = Mock(beg_date='2026-05-01', end_date='2026-05-31')
class ContractDetailMock:
del_period = period
contract_detail = ContractDetailMock()
line = Mock()
ContractFactory._apply_delivery(line, contract_detail, {})
self.assertEqual(line.from_del, '2026-05-01')
self.assertEqual(line.to_del, '2026-05-31')
def test_lot_matching_rejects_purchase_quantity_above_available(self):
'apply matching cannot consume more than available purchase quantity'
class LotQtMock:
def __call__(self, _id):
return Mock(lot_quantity=Decimal('100'))
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
purchase_lot = Mock(
lot_matched_qt=Decimal('101'), lot_r_id=1)
with patch.object(lot_module, 'Pool', return_value=pool):
with self.assertRaises(UserError):
lot_module.LotQt.match_lots([purchase_lot], [])
def test_lot_matching_available_includes_tolerance_remaining(self):
'matching can use remaining purchase tolerance above open quantity'
purchase = Mock(tol_max=Decimal('10'))
line = Mock(
purchase=purchase, inherit_tol=True,
quantity_theorical=Decimal('100'), unit=None)
vlot = Mock(id=10, line=line)
lqt = Mock(lot_p=vlot, lot_quantity=Decimal('60'))
matched_lqt = Mock(lot_quantity=Decimal('40'), lot_unit=None)
with patch.object(
lot_module.LotQt, 'search', return_value=[matched_lqt]):
self.assertEqual(
lot_module.LotQt._matching_tolerated_available(
lqt, 'purchase'),
Decimal('70.00000'))
def test_lot_matching_accepts_purchase_quantity_within_tolerance(self):
'apply matching can consume purchase quantity up to tolerance max'
purchase = Mock(tol_max=Decimal('10'))
line = Mock(
purchase=purchase, inherit_tol=True,
quantity_theorical=Decimal('100'), unit=None)
vlot = Mock(id=10, line=line)
lqt = Mock(lot_p=vlot, lot_s=None, lot_quantity=Decimal('100'))
class LotQtMock:
def __call__(self, _id):
return lqt
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
purchase_lot = Mock(
lot_matched_qt=Decimal('105'), lot_r_id=1)
with patch.object(lot_module, 'Pool', return_value=pool), \
patch.object(lot_module.LotQt, 'search', return_value=[]):
lot_module.LotQt.match_lots([purchase_lot], [])
def test_lot_matching_gauge_uses_entered_quantity_to_match(self):
'go to matching gauge projects tolerance from qt to match'
matching_lot = lot_module.LotMatchingLot()
matching_lot.lot_theoretical_qt = Decimal('100')
matching_lot.lot_already_matched_qt = Decimal('40')
matching_lot.lot_matched_qt = Decimal('65')
matching_lot.lot_tolerance_min = Decimal('-5')
matching_lot.lot_tolerance_max = Decimal('10')
self.assertEqual(
matching_lot.on_change_with_lot_tolerance_used(),
Decimal('5.00000'))
def test_go_matching_defaults_selected_open_lot_qts(self):
'go to matching only preloads selected unmatched open lot.qt rows'
purchase = Mock(id=10, party=Mock(id=20))
sale = Mock(id=30, party=Mock(id=40))
product = Mock(id=50)
purchase_lot = Mock(
id=101,
line=Mock(
purchase=purchase, inherit_tol=False,
quantity_theorical=Decimal('100'),
targeted_qt=Decimal('96'), tol_min=0, tol_max=0),
sale_line=None,
lot_type='virtual', lot_product=product)
sale_lot = Mock(
id=102, line=None,
sale_line=Mock(
sale=sale, inherit_tol=False,
quantity_theorical=Decimal('80'),
targeted_qt=Decimal('84'), tol_min=0, tol_max=0),
lot_type='virtual', lot_product=product)
matched_sale_lot = Mock(
id=103, line=None,
sale_line=Mock(
sale=sale, inherit_tol=False,
quantity_theorical=Decimal('20'), tol_min=0, tol_max=0),
lot_type='virtual', lot_product=product)
lot_qts = {
1: Mock(
id=1, lot_p=purchase_lot, lot_s=None,
lot_quantity=Decimal('100'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None),
2: Mock(
id=2, lot_p=None, lot_s=sale_lot,
lot_quantity=Decimal('80'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None),
3: Mock(
id=3, lot_p=purchase_lot, lot_s=matched_sale_lot,
lot_quantity=Decimal('20'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None),
}
class LotQtMock:
def __call__(self, _id):
return lot_qts[_id]
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
transaction = Mock()
transaction.context = {
'active_ids': [10000001, 10000002, 10000003],
}
with patch.object(lot_module, 'Pool', return_value=pool):
with patch.object(
lot_module, 'Transaction', return_value=transaction):
result = lot_module.LotGoMatching().default_match([])
self.assertEqual(result['qt_type'], 'all')
self.assertEqual(len(result['lot_p']), 1)
self.assertEqual(len(result['lot_s']), 1)
self.assertEqual(result['lot_p'][0]['lot_r_id'], 1)
self.assertEqual(result['lot_p'][0]['lot_quantity'], Decimal('100'))
self.assertEqual(result['lot_p'][0]['lot_targeted_qt'], Decimal('96'))
self.assertEqual(result['lot_p'][0]['lot_qt_min'], Decimal('100.00000'))
self.assertEqual(result['lot_p'][0]['lot_qt_max'], Decimal('100.00000'))
self.assertEqual(result['lot_p'][0]['lot_cp'], 20)
self.assertEqual(result['lot_s'][0]['lot_r_id'], 2)
self.assertEqual(result['lot_s'][0]['lot_quantity'], Decimal('80'))
self.assertEqual(result['lot_s'][0]['lot_targeted_qt'], Decimal('84'))
self.assertEqual(result['lot_s'][0]['lot_qt_min'], Decimal('80.00000'))
self.assertEqual(result['lot_s'][0]['lot_qt_max'], Decimal('80.00000'))
self.assertEqual(result['lot_s'][0]['lot_cp'], 40)
def test_go_matching_defaults_selected_physical_purchase_lot(self):
'go to matching preloads selected unmatched physical purchase lots'
purchase = Mock(id=10, party=Mock(id=20))
sale = Mock(id=30, party=Mock(id=40))
product = Mock(id=50)
physical_lot = Mock(
id=101,
line=Mock(
purchase=purchase, inherit_tol=False,
quantity_theorical=Decimal('100'),
targeted_qt=Decimal('96'), tol_min=0, tol_max=0),
sale_line=None,
lot_type='physic',
lot_product=product,
lot_shipment_in=None,
lot_shipment_internal=None,
lot_shipment_out=None)
physical_lot.get_current_quantity_converted.return_value = (
Decimal('70'))
sale_lot = Mock(
id=102, line=None,
sale_line=Mock(
sale=sale, inherit_tol=False,
quantity_theorical=Decimal('80'),
targeted_qt=Decimal('84'), tol_min=0, tol_max=0),
lot_type='virtual', lot_product=product)
sale_lqt = Mock(
id=2, lot_p=None, lot_s=sale_lot,
lot_quantity=Decimal('80'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None)
class LotQtMock:
def __call__(self, _id):
return sale_lqt
class LotMock:
def __call__(self, _id):
return physical_lot
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else LotMock())
transaction = Mock()
transaction.context = {
'active_ids': [101, 10000002],
}
with patch.object(lot_module, 'Pool', return_value=pool):
with patch.object(
lot_module, 'Transaction', return_value=transaction):
result = lot_module.LotGoMatching().default_match([])
self.assertEqual(len(result['lot_p']), 1)
self.assertEqual(len(result['lot_s']), 1)
self.assertEqual(result['lot_p'][0]['lot_id'], 101)
self.assertIsNone(result['lot_p'][0]['lot_r_id'])
self.assertEqual(result['lot_p'][0]['lot_type'], 'physic')
self.assertEqual(result['lot_p'][0]['lot_quantity'], Decimal('70'))
self.assertEqual(result['lot_p'][0]['lot_targeted_qt'], Decimal('96'))
self.assertEqual(result['lot_p'][0]['lot_cp'], 20)
self.assertEqual(result['lot_s'][0]['lot_r_id'], 2)
def test_go_matching_requires_purchase_and_sale_selection(self):
'go to matching requires one purchase and one sale row'
wizard = lot_module.LotGoMatching()
wizard.match = Mock(lot_p=[], lot_s=[Mock()])
with self.assertRaises(UserError):
wizard.transition_matching()
def test_physical_purchase_matching_keeps_empty_sale_lot_qt(self):
'physical purchase matching does not fail when sale lot_qt is empty'
physical_lot = Mock(
id=101,
lot_type='physic',
sale_line=None)
physical_lot.get_current_quantity_converted.return_value = (
Decimal('10'))
physical_lot.getVlot_p.return_value = Mock()
physical_lot.updateVirtualPart.return_value = True
sale_virtual_lot = Mock(
sale_line=Mock(),
lot_quantity=Decimal('9'),
lot_qt=None)
sale_virtual_lot.getVlot_s.return_value = sale_virtual_lot
sale_lqt = Mock(
lot_p=None,
lot_s=sale_virtual_lot,
lot_quantity=Decimal('9'),
lot_unit=None)
class LotQtMock:
def __call__(self, _id):
return sale_lqt
@staticmethod
def search(_domain):
return []
@staticmethod
def save(_records):
return None
class LotMock:
def __call__(self, _id):
return physical_lot
@staticmethod
def save(_records):
return None
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else LotMock())
purchase_match = Mock(
lot_id=101,
lot_r_id=None,
lot_matched_qt=Decimal('1'))
sale_match = Mock(
lot_r_id=2,
lot_matched_qt=Decimal('1'))
with patch.object(lot_module, 'Pool', return_value=pool), \
patch.object(lot_module.LotQt, 'search', return_value=[]):
lot_module.LotQt.match_lots([purchase_match], [sale_match])
self.assertIsNone(sale_virtual_lot.lot_qt)
self.assertEqual(sale_virtual_lot.lot_quantity, Decimal('8'))
def test_purchase_tolerance_used_is_weighted_line_average(self):
'purchase tolerance gauge averages line gauges by theoretical quantity'
unit = Mock()
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('105')
physical_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('100'),
lots=[physical])
virtual = Mock(id=10, lot_type='virtual')
open_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('300'),
lots=[virtual])
purchase = purchase_module.Purchase()
purchase.lines = [physical_line, open_line]
purchase.tol_min = Decimal('5')
purchase.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('270'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(purchase_module, 'Pool', return_value=pool):
self.assertEqual(
purchase.get_tolerance_used('tolerance_used'),
Decimal('-6.25000'))
self.assertEqual(
purchase.get_tolerance_min('tolerance_min'),
Decimal('-5'))
self.assertEqual(
purchase.get_tolerance_max('tolerance_max'),
Decimal('10'))
def test_sale_tolerance_used_is_weighted_line_average(self):
'sale tolerance gauge averages line gauges by theoretical quantity'
unit = Mock()
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('105')
physical_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('100'),
lots=[physical])
virtual = Mock(id=20, lot_type='virtual')
open_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('300'),
lots=[virtual])
sale = sale_module.Sale()
sale.lines = [physical_line, open_line]
sale.tol_min = Decimal('5')
sale.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('-270'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(sale_module, 'Pool', return_value=pool):
self.assertEqual(
sale.get_tolerance_used('tolerance_used'),
Decimal('-6.25000'))
self.assertEqual(
sale.get_tolerance_min('tolerance_min'),
Decimal('-5'))
self.assertEqual(
sale.get_tolerance_max('tolerance_max'),
Decimal('10'))
def test_purchase_line_tolerance_uses_physical_lots_when_present(self):
'purchase line tolerance uses the editable targeted quantity'
unit = Mock()
virtual = Mock(id=10, lot_type='virtual')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('105')
line = purchase_module.Line()
line.unit = unit
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('105')
line.lots = [virtual, physical]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('80'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(purchase_module, 'Pool', return_value=pool):
self.assertEqual(
line.get_tolerance_used('tolerance_used'),
Decimal('5.00000'))
lot_qt_model.search.assert_not_called()
def test_sale_line_tolerance_uses_open_lot_qt_without_physical_lots(self):
'sale line tolerance uses the editable targeted quantity'
unit = Mock()
virtual = Mock(id=20, lot_type='virtual')
line = sale_module.SaleLine()
line.unit = unit
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('95')
line.lots = [virtual]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('-95'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(sale_module, 'Pool', return_value=pool):
self.assertEqual(
line.get_tolerance_used('tolerance_used'),
Decimal('-5.00000'))
def test_purchase_line_targeted_qt_blocks_above_tolerance(self):
'purchase line targeted quantity must stay inside tolerance bounds'
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('111')
line = purchase_module.Line()
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('111')
line.lots = [physical]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
with self.assertRaises(UserError):
line.check_targeted_qt_tolerance()
def test_sale_line_targeted_qt_blocks_below_tolerance(self):
'sale line targeted quantity must not fall below tolerance minimum'
unit = Mock()
virtual = Mock(id=20, lot_type='virtual')
line = sale_module.SaleLine()
line.unit = unit
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('94')
line.lots = [virtual]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('-94'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(sale_module, 'Pool', return_value=pool):
with self.assertRaises(UserError):
line.check_targeted_qt_tolerance()
def test_purchase_line_initial_targeted_qt_defaults_to_contract_quantity(self):
'purchase line targeted quantity defaults to contractual quantity'
values = {'quantity_theorical': Decimal('100')}
purchase_module.Line._set_initial_quantity_values(values)
self.assertEqual(values['targeted_qt'], Decimal('100.00000'))
def test_sale_line_initial_targeted_qt_defaults_to_contract_quantity(self):
'sale line targeted quantity defaults to contractual quantity'
values = {'quantity_theorical': Decimal('100')}
sale_module.SaleLine._set_initial_quantity_values(values)
self.assertEqual(values['targeted_qt'], Decimal('100.00000'))
def test_lot_context_group_defaults_to_all(self):
'lots management opens without grouping by physical lot'
self.assertEqual(lot_module.LotContext.default_group(), 'all')
def test_contract_detail_defaults_sale_locations_from_dropship_purchase(self):
'create contracts copies supplier-to-customer locations from purchase to sale'
supplier = Mock(id=1, type='supplier')
customer = Mock(id=2, type='customer')
purchase = Mock(from_location=supplier, to_location=customer)
lqt = Mock(
lot_p=Mock(line=Mock(purchase=purchase)),
lot_s=None)
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertEqual(lot_module.ContractDetail.default_from_location(), 1)
self.assertEqual(lot_module.ContractDetail.default_to_location(), 2)
def test_contract_detail_defaults_sale_from_purchase_stock_destination(self):
'create contracts uses purchase stock destination as sale source'
supplier = Mock(id=1, type='supplier')
storage = Mock(id=3, type='storage')
purchase = Mock(from_location=supplier, to_location=storage)
lqt = Mock(
lot_p=Mock(line=Mock(purchase=purchase)),
lot_s=None)
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertEqual(lot_module.ContractDetail.default_from_location(), 3)
self.assertIsNone(lot_module.ContractDetail.default_to_location())
def test_contract_detail_defaults_purchase_locations_from_dropship_sale(self):
'create contracts copies supplier-to-customer locations from sale to purchase'
supplier = Mock(id=1, type='supplier')
customer = Mock(id=2, type='customer')
sale = Mock(from_location=supplier, to_location=customer)
lqt = Mock(
lot_p=None,
lot_s=Mock(sale_line=Mock(sale=sale)))
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertEqual(lot_module.ContractDetail.default_from_location(), 1)
self.assertEqual(lot_module.ContractDetail.default_to_location(), 2)
def test_contract_detail_defaults_purchase_to_sale_stock_source(self):
'create contracts uses sale stock source as purchase destination'
storage = Mock(id=3, type='storage')
customer = Mock(id=2, type='customer')
sale = Mock(from_location=storage, to_location=customer)
lqt = Mock(
lot_p=None,
lot_s=Mock(sale_line=Mock(sale=sale)))
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertIsNone(lot_module.ContractDetail.default_from_location())
self.assertEqual(lot_module.ContractDetail.default_to_location(), 3)
def test_contract_detail_hides_melya_company_fields(self):
'create contracts mirrors Melya company field visibility'
company = Mock(party=Mock(name='MELYA'))
company_model = Mock(return_value=company)
transaction = Mock()
transaction.context = {'company': 42}
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock, \
patch('trytond.modules.purchase_trade.lot.Transaction',
return_value=transaction):
PoolMock.return_value.get.return_value = company_model
self.assertTrue(
lot_module.ContractDetail.default_company_visible())
def test_sale_report_price_lines_basis_displays_premium_only(self):
'basis report pricing displays only the premium in templates'
Sale = Pool().get('sale.sale')
line = Mock()
line.type = 'line'
line.price_type = 'basis'
line.enable_linked_currency = True
line.linked_currency = Mock(rec_name='USC')
line.linked_unit = Mock(rec_name='POUND')
line.unit = Mock(rec_name='MT')
line.unit_price = Decimal('1598.3495')
line.linked_price = Decimal('72.5000')
line.premium = Decimal('8.3000')
line.get_pricing_text = 'ON ICE Cotton #2 MARCH 2026'
sale = Sale()
sale.currency = Mock(rec_name='USD')
sale.lines = [line]
self.assertEqual(
sale.report_price_lines,
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026')
def test_sale_report_net_and_gross_sum_all_lines(self):
'sale report totals aggregate every line instead of the first one only'
Sale = Pool().get('sale.sale')
def make_lot(quantity):
lot = Mock()
lot.lot_type = 'physic'
lot.get_current_quantity.return_value = Decimal(quantity)
lot.get_current_gross_quantity.return_value = Decimal(quantity)
return lot
line_a = Mock(type='line', quantity=Decimal('1000'))
line_a.lots = [make_lot('1000')]
line_b = Mock(type='line', quantity=Decimal('1000'))
line_b.lots = [make_lot('1000')]
sale = Sale()
sale.lines = [line_a, line_b]
self.assertEqual(sale.report_net, Decimal('2000'))
self.assertEqual(sale.report_gross, Decimal('2000'))
def test_sale_report_trade_blocks_use_lot_current_quantity(self):
'sale trade blocks use current lot quantity for quantity display'
Sale = Pool().get('sale.sale')
lot = Mock()
lot.lot_type = 'physic'
lot.get_current_quantity.return_value = Decimal('950')
line = Mock()
line.type = 'line'
line.lots = [lot]
line.quantity = Decimal('1000')
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description='MARCH 2026')
line.price_type = 'priced'
line.linked_currency = Mock(rec_name='USC')
line.linked_unit = Mock(rec_name='POUND')
line.linked_price = Decimal('8.3000')
line.unit_price = Decimal('0')
line.get_pricing_text = 'ON ICE Cotton #2 MARCH 2026'
sale = Sale()
sale.currency = Mock(rec_name='USD')
sale.lines = [line]
self.assertEqual(
sale.report_trade_blocks,
[(
'950.0 MT (NINE HUNDRED AND FIFTY METRIC TONS) - MARCH 2026',
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026',
)])
def test_sale_report_uses_single_virtual_lot_hist_when_no_physical(self):
'sale report uses the unique virtual lot hist when no physical lot exists'
Sale = Pool().get('sale.sale')
virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
virtual.get_hist_quantity.return_value = (
Decimal('930'),
Decimal('0'),
)
line = Mock(type='line', quantity=Decimal('1000'))
line.lots = [virtual]
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description='MARCH 2026')
sale = Sale()
sale.lines = [line]
self.assertEqual(sale.report_net, Decimal('930'))
self.assertEqual(sale.report_gross, Decimal('930'))
self.assertEqual(sale.report_total_quantity, '930.0')
self.assertEqual(sale.report_quantity_unit_upper, 'LBS')
self.assertEqual(
sale.report_quantity_lines,
'930.0 LBS (NINE HUNDRED AND THIRTY POUNDS) - MARCH 2026')
def test_sale_report_prefers_physical_lot_hist_over_virtual(self):
'sale report prioritizes physical lot hist values over virtual ones'
Sale = Pool().get('sale.sale')
virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
virtual.get_hist_quantity.return_value = (
Decimal('930'),
Decimal('940'),
)
physical = Mock(lot_type='physic', lot_unit_line=Mock(rec_name='LBS'))
physical.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
line = Mock(type='line', quantity=Decimal('1000'))
line.lots = [virtual, physical]
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description='MARCH 2026')
sale = Sale()
sale.lines = [line]
self.assertEqual(sale.report_net, Decimal('950'))
self.assertEqual(sale.report_gross, Decimal('980'))
self.assertEqual(sale.report_total_quantity, '950.0')
self.assertEqual(sale.report_quantity_unit_upper, 'LBS')
self.assertEqual(
sale.report_quantity_lines,
'950.0 LBS (NINE HUNDRED AND FIFTY POUNDS) - MARCH 2026')
def test_invoice_report_note_title_uses_sale_direction(self):
'sale final note title is inverted from the raw amount sign'
Invoice = Pool().get('account.invoice')
debit = Invoice()
debit.type = 'out'
debit.total_amount = Decimal('10')
self.assertEqual(debit.report_note_title, 'Debit Note')
credit = Invoice()
credit.type = 'out'
credit.total_amount = Decimal('-10')
self.assertEqual(credit.report_note_title, 'Credit Note')
def test_invoice_report_note_title_keeps_inverse_for_purchase(self):
'purchase final note title keeps the opposite mapping'
Invoice = Pool().get('account.invoice')
credit = Invoice()
credit.type = 'in'
credit.total_amount = Decimal('10')
self.assertEqual(credit.report_note_title, 'Credit Note')
debit = Invoice()
debit.type = 'in'
debit.total_amount = Decimal('-10')
self.assertEqual(debit.report_note_title, 'Debit Note')
def test_invoice_report_net_sums_signed_invoice_lines(self):
'invoice report net uses the signed differential from invoice lines'
Invoice = Pool().get('account.invoice')
line_a = Mock(type='line', quantity=Decimal('1000'))
line_b = Mock(type='line', quantity=Decimal('-200'))
invoice = Invoice()
invoice.lines = [line_a, line_b]
self.assertEqual(invoice.report_net, Decimal('800'))
def test_invoice_report_weights_use_current_lot_hist_values(self):
'invoice net and gross weights come from the current lot hist entry'
Invoice = Pool().get('account.invoice')
unit = Mock(rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit)
lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
line = Mock(type='line', quantity=Decimal('1000'), lot=lot, unit=Mock(rec_name='MT'))
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('950'))
self.assertEqual(invoice.report_gross, Decimal('980'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
self.assertEqual(
invoice.report_quantity_lines,
'950.00 LBS (950.00 LBS)')
def test_invoice_report_weights_prefer_invoice_line_snapshot(self):
'invoice report weights use the invoicing snapshot when available'
Invoice = Pool().get('account.invoice')
unit = Mock(rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit)
lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
snapshot = Mock(
net_quantity=Decimal('900'),
gross_quantity=Decimal('920'),
unit=unit,
)
line = Mock(
type='line',
quantity=Decimal('1000'),
lot=lot,
unit=Mock(rec_name='MT'),
lot_weight_snapshots=[snapshot],
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('900'))
self.assertEqual(invoice.report_gross, Decimal('920'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
self.assertEqual(
invoice.report_quantity_lines,
'900.00 LBS (900.00 LBS)')
def test_invoice_report_proforma_snapshot_adds_sale_padding(self):
'ICT provisional report displays snapshot weights plus sale padding'
Invoice = Pool().get('account.invoice')
unit = Mock(id=1, rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit, sale_invoice_padding=Decimal('10'))
snapshot = Mock(
net_quantity=Decimal('900'),
gross_quantity=Decimal('920'),
unit=unit,
)
line = Mock(
type='line',
description='Pro forma',
invoice_type='out',
quantity=Decimal('1010'),
lot=lot,
unit=unit,
lot_weight_snapshots=[snapshot],
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('910'))
self.assertEqual(invoice.report_gross, Decimal('930'))
self.assertEqual(
invoice.report_quantity_lines,
'910.00 LBS (910.00 LBS)')
def test_invoice_validation_creates_weight_snapshot_for_proforma_lines(self):
'invoice validation snapshots lot weights for pro forma invoice lines'
Invoice = Pool().get('account.invoice')
lot_ref = Mock(id=10)
lot = Mock(id=10)
proforma = Mock(
type='line',
description='Pro forma',
quantity=Decimal('1000'),
lot=lot_ref,
)
final = Mock(
type='line',
description='Final',
quantity=Decimal('1000'),
lot=lot_ref,
)
invoice = Invoice()
invoice.lines = [proforma, final]
snapshot_model = Mock()
lot_model = Mock(return_value=lot)
def get_model(name):
if name == 'account.invoice.line.lot.weight':
return snapshot_model
if name == 'lot.lot':
return lot_model
raise AssertionError(name)
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = get_model
invoice._create_lot_weight_snapshots()
snapshot_model.create_for_invoice_line.assert_called_once_with(
proforma, lot)
def test_invoice_report_lbs_converts_kilogram_to_lbs(self):
'invoice lbs helper converts kilogram quantities with the proper uom ratio'
Invoice = Pool().get('account.invoice')
kg = Mock(id=1, rec_name='KILOGRAM', symbol='KG')
lbs = Mock(id=2, rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=kg)
lot.get_hist_quantity.return_value = (
Decimal('999995'),
Decimal('999995'),
)
line = Mock(type='line', quantity=Decimal('999995'), lot=lot, unit=kg)
invoice = Invoice()
invoice.lines = [line]
uom_model = Mock()
uom_model.search.return_value = [lbs]
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: qty * 2.20462
)
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(invoice.report_lbs, Decimal('2204608.98'))
self.assertEqual(
invoice.report_quantity_lines,
'999995.00 KILOGRAM (2204608.98 LBS)')
self.assertEqual(invoice.report_net_display, '999995.00')
self.assertEqual(invoice.report_lbs_display, '2204608.98')
def test_invoice_report_weights_keep_line_sign_with_lot_hist_values(self):
'invoice lot hist values keep the invoice line sign for final notes'
Invoice = Pool().get('account.invoice')
positive_lot = Mock(lot_unit_line=Mock(rec_name='LBS'))
positive_lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
negative_lot = Mock(lot_unit_line=Mock(rec_name='LBS'))
negative_lot.get_hist_quantity.return_value = (
Decimal('150'),
Decimal('160'),
)
positive = Mock(type='line', quantity=Decimal('1000'), lot=positive_lot)
negative = Mock(type='line', quantity=Decimal('-200'), lot=negative_lot)
invoice = Invoice()
invoice.lines = [positive, negative]
self.assertEqual(invoice.report_net, Decimal('800'))
self.assertEqual(invoice.report_gross, Decimal('820'))
def test_invoice_report_uses_line_quantities_when_same_lot_is_invoiced_twice(self):
'invoice final note keeps line differences when two lines share the same lot'
Invoice = Pool().get('account.invoice')
mt = Mock(id=1, rec_name='MT')
kg = Mock(id=2, rec_name='KILOGRAM')
current_type = Mock(id=100)
previous_type = Mock(id=200)
shared_lot = Mock(id=10, lot_type='physic', lot_unit_line=kg)
shared_lot.lot_state = current_type
shared_lot.get_hist_quantity.return_value = (
Decimal('999995'),
Decimal('999992'),
)
shared_lot.lot_hist = [
Mock(
quantity_type=previous_type,
quantity=Decimal('999995'),
gross_quantity=Decimal('999998'),
),
Mock(
quantity_type=current_type,
quantity=Decimal('999990'),
gross_quantity=Decimal('999992'),
),
]
negative = Mock(
type='line',
quantity=Decimal('-999.995'),
unit=mt,
lot=shared_lot,
)
positive = Mock(
type='line',
quantity=Decimal('999.990'),
unit=mt,
lot=shared_lot,
)
invoice = Invoice()
negative.invoice = invoice
positive.invoice = invoice
invoice.lines = [negative, positive]
uom_model = Mock()
uom_model.search.return_value = [Mock(id=3, rec_name='LBS', symbol='LBS')]
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: (
qty * 1000
if getattr(from_unit, 'rec_name', None) == 'MT'
and getattr(to_unit, 'rec_name', None) == 'KILOGRAM'
else (
qty * 2.20462
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
and getattr(to_unit, 'rec_name', None) == 'LBS'
else qty
)
)
)
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(invoice.report_net, Decimal('-5.000'))
self.assertEqual(invoice.report_gross, Decimal('-6'))
self.assertEqual(
invoice.report_quantity_lines.splitlines(),
[
'-999995.0 KILOGRAM (-2204608.98 LBS)',
'999990.0 KILOGRAM (2204597.95 LBS)',
])
def test_invoice_report_weights_use_single_virtual_lot_when_no_physical(self):
'invoice uses the unique virtual lot hist when no physical lot exists'
Invoice = Pool().get('account.invoice')
virtual = Mock(id=1, lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
virtual.get_hist_quantity.return_value = (
Decimal('930'),
Decimal('0'),
)
origin = Mock(lots=[virtual])
line = Mock(
type='line',
quantity=Decimal('1000'),
lot=None,
origin=origin,
unit=Mock(rec_name='MT'),
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('930'))
self.assertEqual(invoice.report_gross, Decimal('930'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
def test_invoice_report_weights_prefer_physical_lots_over_virtual(self):
'invoice uses physical lot hist values whenever physical lots exist'
Invoice = Pool().get('account.invoice')
virtual = Mock(id=1, lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
virtual.get_hist_quantity.return_value = (
Decimal('930'),
Decimal('940'),
)
physical = Mock(id=2, lot_type='physic', lot_unit_line=Mock(rec_name='LBS'))
physical.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
origin = Mock(lots=[virtual, physical])
line = Mock(
type='line',
quantity=Decimal('1000'),
lot=virtual,
origin=origin,
unit=Mock(rec_name='MT'),
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('950'))
self.assertEqual(invoice.report_gross, Decimal('980'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
def test_invoice_report_shipment_uses_invoice_line_lot_not_first_trade_line(self):
'invoice shipment info comes from the lots linked to the invoiced line'
Invoice = Pool().get('account.invoice')
shipment_a = Mock(
id=1,
bl_date='2026-04-01',
bl_number='BL-A',
vessel=Mock(vessel_name='VESSEL A'),
from_location=Mock(rec_name='LOADING A'),
to_location=Mock(rec_name='DISCHARGE A'),
controller=Mock(rec_name='CTRL A'),
number='SI-A',
)
shipment_b = Mock(
id=2,
bl_date='2026-04-05',
bl_number='BL-B',
vessel=Mock(vessel_name='VESSEL B'),
from_location=Mock(rec_name='LOADING B'),
to_location=Mock(rec_name='DISCHARGE B'),
controller=Mock(rec_name='CTRL B'),
number='SI-B',
)
lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a)
lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b)
line_a = Mock(lots=[lot_a])
line_b = Mock(lots=[lot_b])
purchase = Mock(lines=[line_a, line_b])
invoice_line = Mock(type='line', lot=lot_b, origin=line_b)
invoice = Invoice()
invoice.purchases = [purchase]
invoice.lines = [invoice_line]
self.assertEqual(invoice.report_bl_nb, 'BL-B')
self.assertEqual(invoice.report_bl_date, '2026-04-05')
self.assertEqual(invoice.report_vessel, 'VESSEL B')
self.assertEqual(invoice.report_loading_port, 'LOADING B')
self.assertEqual(invoice.report_discharge_port, 'DISCHARGE B')
self.assertEqual(invoice.report_controller_name, 'CTRL B')
self.assertEqual(invoice.report_si_number, 'SI-B')
self.assertEqual(invoice.report_si_reference, 'REF-B')
def test_shipment_in_truck_defaults_empty_cargo_mode(self):
'truck shipment clears vessel data and uses empty cargo mode'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.transport_type = 'truck'
shipment.vessel = Mock()
shipment.cargo_mode = 'bulk'
shipment.on_change_transport_type()
self.assertIsNone(shipment.vessel)
self.assertEqual(shipment.cargo_mode, 'none')
self.assertEqual(
ShipmentIn._normalize_transport_values({
'transport_type': 'truck',
'cargo_mode': 'bulk',
'vessel': 42,
}),
{
'transport_type': 'truck',
'cargo_mode': 'none',
'vessel': None,
})
def test_shipment_in_write_ignores_empty_record_groups(self):
'shipment write normalization keeps empty internal writes as no-op'
ShipmentIn = Pool().get('stock.shipment.in')
self.assertEqual(
ShipmentIn._normalize_transport_write_args((
[],
{'cost_price': Decimal('12.34')},
)),
[])
def test_invoice_report_transportation_uses_truck_label(self):
'invoice_melya Transportation displays By Truck for truck shipments'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice._get_report_shipment = Mock(return_value=Mock(
transport_type='truck',
supplier=Mock(rec_name='SUPPLIER'),
vessel=Mock(vessel_name='VESSEL A'),
note='ignored',
))
self.assertEqual(invoice.report_transportation, 'By Truck')
def test_invoice_report_transportation_uses_vessel_name_only(self):
'invoice_melya Transportation displays only vessel name for vessels'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice._get_report_shipment = Mock(return_value=Mock(
transport_type='vessel',
supplier=Mock(rec_name='SUPPLIER'),
vessel=Mock(vessel_name='VESSEL A'),
note='ignored',
))
self.assertEqual(invoice.report_transportation, 'VESSEL A')
def test_invoice_report_shipment_is_blank_if_invoice_mixes_shipments(self):
'invoice shipment fields stay empty when multiple shipments are invoiced together'
Invoice = Pool().get('account.invoice')
shipment_a = Mock(
id=1,
bl_date='2026-04-01',
bl_number='BL-A',
vessel=Mock(vessel_name='VESSEL A'),
from_location=Mock(rec_name='LOADING A'),
to_location=Mock(rec_name='DISCHARGE A'),
controller=Mock(rec_name='CTRL A'),
number='SI-A',
)
shipment_b = Mock(
id=2,
bl_date='2026-04-05',
bl_number='BL-B',
reference='REF-B',
vessel=Mock(vessel_name='VESSEL B'),
from_location=Mock(rec_name='LOADING B'),
to_location=Mock(rec_name='DISCHARGE B'),
controller=Mock(rec_name='CTRL B'),
number='SI-B',
)
lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a)
lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b)
line_a = Mock(type='line', lot=lot_a, origin=Mock(lots=[lot_a]))
line_b = Mock(type='line', lot=lot_b, origin=Mock(lots=[lot_b]))
invoice = Invoice()
invoice.lines = [line_a, line_b]
self.assertIsNone(invoice.report_bl_nb)
self.assertIsNone(invoice.report_bl_date)
self.assertEqual(invoice.report_vessel, None)
self.assertEqual(invoice.report_loading_port, '')
self.assertEqual(invoice.report_discharge_port, '')
self.assertEqual(invoice.report_controller_name, '')
self.assertEqual(invoice.report_si_number, '')
self.assertEqual(invoice.report_si_reference, '')
def test_invoice_report_nb_bale_sums_signed_line_lot_quantities(self):
'invoice reports packaging from the signed sum of line lot_qt values'
Invoice = Pool().get('account.invoice')
lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale'))
negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot)
positive = Mock(type='line', quantity=Decimal('1000'), lot=lot)
invoice = Invoice()
invoice.lines = [negative, positive]
self.assertEqual(invoice.report_nb_bale, 'NB BALES: 0')
def test_invoice_report_cndn_nb_bale_displays_unchanged_for_zero(self):
'CN/DN bale label displays Unchanged when the signed balance is zero'
Invoice = Pool().get('account.invoice')
lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale'))
negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot)
positive = Mock(type='line', quantity=Decimal('1000'), lot=lot)
invoice = Invoice()
invoice.lines = [negative, positive]
self.assertEqual(invoice.report_cndn_nb_bale, 'Unchanged')
def test_invoice_report_packaging_displays_quantity_before_unit(self):
'invoice packaging label displays quantity before package unit'
Invoice = Pool().get('account.invoice')
lot = Mock(lot_qt=Decimal('990'), lot_unit=Mock(symbol='BAGS25'))
line = Mock(type='line', quantity=Decimal('1000'), lot=lot)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_nb_bale, 'NB BAGS25: 990')
self.assertEqual(invoice.report_packaging, '990 BAGS25')
def test_invoice_report_positive_rate_lines_keep_positive_components(self):
'invoice final note pricing section keeps only positive component lines'
Invoice = Pool().get('account.invoice')
sale = Mock()
sale.report_price_lines = (
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026\n'
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MAY 2026'
)
invoice = Invoice()
invoice.sales = [sale]
invoice.lines = []
self.assertEqual(
invoice.report_positive_rate_lines.splitlines(),
[
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026',
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MAY 2026',
])
def test_lot_invoice_sale_uses_sale_invoice_line_reference(self):
'sale invoicing must resolve the generated invoice from sale invoice links'
sale_invoice = Mock()
sale_invoice_line = Mock(invoice=sale_invoice)
lot = Mock(
sale_invoice_line=sale_invoice_line,
sale_invoice_line_prov=None,
invoice_line=None,
invoice_line_prov=None,
)
invoice_line = lot.sale_invoice_line or lot.sale_invoice_line_prov
self.assertIs(invoice_line.invoice, sale_invoice)
def test_lot_invoice_sale_padding_is_split_per_lot(self):
'sale provisional padding is split equally across selected lots'
lots = [Mock(id=1), Mock(id=2)]
self.assertEqual(
lot_module.LotInvoice._split_sale_padding(Decimal('1000'), lots),
{1: Decimal('500'), 2: Decimal('500')})
@with_transaction()
def test_invoice_line_included_padding_reads_sale_lot_padding(self):
'invoice line exposes the sale provisional padding stored on the lot'
InvoiceLine = Pool().get('account.invoice.line')
line = InvoiceLine()
line.lot = Mock(sale_invoice_padding=Decimal('500'))
line.invoice_type = 'out'
line.description = 'Pro forma'
line.quantity = Decimal('10500')
self.assertEqual(line.get_included_padding(None), Decimal('500'))
del ModuleTestCase