# 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.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_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_pricing_manual_fields_are_editable_except_eod(self): 'manual pricing rows can edit fixed and unfixed values while eod stays computed' Pricing = Pool().get('pricing.pricing') self.assertFalse(Pricing.fixed_qt.readonly) self.assertFalse(Pricing.fixed_qt_price.readonly) self.assertFalse(Pricing.unfixed_qt.readonly) self.assertFalse(Pricing.unfixed_qt_price.readonly) self.assertTrue(Pricing.eod_price.readonly) 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_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_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.unit = Mock() 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.return_value = [lqt] with patch( 'trytond.modules.purchase_trade.sale.Pool' ) as PoolMock, patch( 'trytond.modules.purchase_trade.sale.super' ) as super_mock: 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 SaleLine.write([line], {'quantity_theorical': Decimal('12')}) self.assertEqual(lqt.lot_quantity, Decimal('12')) vlot.set_current_quantity.assert_called_once_with( Decimal('12'), Decimal('12'), 1) lot_model.save.assert_called() lotqt_model.save.assert_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')) 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')) lot_model = Mock() lotqt_model = Mock() lotqt_model.search.return_value = [lqt] with patch( 'trytond.modules.purchase_trade.sale.Pool' ) as PoolMock, patch( 'trytond.modules.purchase_trade.sale.super' ) as super_mock: 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 with self.assertRaises(UserError): SaleLine.write([line], {'quantity_theorical': Decimal('8')}) 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_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_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') 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_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', }) 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') 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_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( packing_report._resolve_configured_report_path({ 'name': 'Packing List', 'report': 'stock/packing_list.fodt', }), 'stock/packing_list_custom.fodt') 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_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) del ModuleTestCase