Files
tradon/modules/purchase_trade/tests/test_module.py
2026-05-17 17:22:40 +02:00

3516 lines
140 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 Mock, patch
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.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.service import ContractFactory
class PurchaseTradeTestCase(ModuleTestCase):
'Test purchase_trade module'
module = 'purchase_trade'
@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)))
@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'))
def test_get_strategy_mtm_price_returns_unit_price(self):
'strategy mtm price exposes the raw unit valuation price'
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('100.0000'))
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'))
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_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(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='rate',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=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_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,
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values[0]['amount'], Decimal('0'))
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_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,
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_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_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)]
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,
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,
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
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_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_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,
}[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()
@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_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, price_index=Mock(get_price=Mock(return_value=Decimal('1'))))
pc_purchase = Mock(id=1, quota=Decimal('3'), pricing_date=None, price_index=Mock(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_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_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')
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')])
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.terms = []
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_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_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',
)
]
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': '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_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_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': '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')
]
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')
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(party=sale_party)
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_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_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_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_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_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_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'), 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'), 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_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_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_purchase_tolerance_used_sums_open_and_physical_quantities(self):
'purchase tolerance gauge uses lot.qt plus physical lot quantities'
unit = Mock()
virtual = Mock(id=10, lot_type='virtual')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('10')
line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('100'),
lots=[virtual, physical])
purchase = purchase_module.Purchase()
purchase.lines = [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('95'), 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('5.00000'))
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_sums_open_and_physical_quantities(self):
'sale tolerance gauge uses absolute lot.qt plus physical quantities'
unit = Mock()
virtual = Mock(id=20, lot_type='virtual')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('10')
line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('100'),
lots=[virtual, physical])
sale = sale_module.Sale()
sale.lines = [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('-95'), 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('5.00000'))
self.assertEqual(
sale.get_tolerance_min('tolerance_min'),
Decimal('-5'))
self.assertEqual(
sale.get_tolerance_max('tolerance_max'),
Decimal('10'))
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_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_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_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