# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from decimal import Decimal from unittest.mock import Mock, patch from trytond.pool import Pool 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.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_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_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_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_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_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_invoice_report_note_title_uses_total_amount_sign(self): 'final invoice title switches between credit and debit note' Invoice = Pool().get('account.invoice') credit = Invoice() credit.total_amount = Decimal('10') self.assertEqual(credit.report_note_title, 'Credit Note') debit = Invoice() 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_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_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) del ModuleTestCase