3516 lines
140 KiB
Python
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
|