Files
tradon/modules/purchase_trade/tests/test_module.py
2026-07-16 20:37:17 +02:00

10550 lines
409 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
import zipfile
from pathlib import Path
from contextlib import nullcontext
from decimal import Decimal
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import ANY, Mock, call, patch
from xml.etree import ElementTree
from trytond.pool import Pool
from trytond.pyson import Eval, PYSONDecoder
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.exceptions import UserError, UserWarning
from trytond.transaction import Transaction
from trytond.modules.purchase import purchase as base_purchase_module
from trytond.modules.purchase_trade import valuation as valuation_module
from trytond.modules.purchase_trade import lot as lot_module
from trytond.modules.purchase_trade import purchase as purchase_module
from trytond.modules.purchase_trade import sale as sale_module
from trytond.modules.purchase_trade import stock as stock_module
from trytond.modules.purchase_trade import fee as fee_module
from trytond.modules.purchase_trade import pricing as pricing_module
from trytond.modules.purchase_trade import duplicate as duplicate_module
from trytond.modules.purchase_trade import coffee as coffee_module
from trytond.modules.purchase_trade import company_defaults
from trytond.modules.purchase_trade.service import ContractFactory
class PurchaseTradeTestCase(ModuleTestCase):
'Test purchase_trade module'
module = 'purchase_trade'
def test_coffee_cupping_result_score_sums_configured_criteria(self):
'coffee cupping result score sums criteria and subtracts defects'
score = SimpleNamespace(subtract_from_score=False)
defect = SimpleNamespace(subtract_from_score=True)
result = SimpleNamespace(lines=[
SimpleNamespace(score=Decimal('8.00'), criterion=score),
SimpleNamespace(score=Decimal('8.25'), criterion=score),
SimpleNamespace(score=Decimal('2.00'), criterion=defect),
])
self.assertEqual(
coffee_module.CoffeeCuppingResult.get_total_score(result),
Decimal('14.25'))
def test_coffee_cupping_session_counts_requested_cups(self):
'coffee cupping session capacity is based on sample cups'
session = SimpleNamespace(cups_per_sample=3)
lines = [
SimpleNamespace(cups_per_sample=None, session=session),
SimpleNamespace(cups_per_sample=4, session=session),
]
self.assertEqual(
coffee_module.CoffeeCuppingSession._total_requested_cups(lines),
7)
def test_coffee_cupping_session_orders_arabica_before_robusta(self):
'coffee cupping session tasting order keeps robusta last'
arabica = SimpleNamespace(
id=2,
reference='A',
get_public_origin=lambda: 'Colombia',
get_public_coffee_type=lambda: 'arabica')
robusta = SimpleNamespace(
id=1,
reference='R',
get_public_origin=lambda: 'Vietnam',
get_public_coffee_type=lambda: 'robusta')
lines = [
SimpleNamespace(sample=robusta),
SimpleNamespace(sample=arabica),
]
ordered = sorted(
lines, key=coffee_module.CoffeeCuppingSession._line_sort_key)
self.assertEqual([line.sample.reference for line in ordered], ['A', 'R'])
def test_coffee_line_attributes_name_uses_quality_fields(self):
'coffee line attribute name is built from coffee quality fields'
line = SimpleNamespace(
coffee_origin='Brazil',
coffee_type='arabica',
coffee_process='natural',
coffee_variety='Bourbon',
coffee_crop_year='26/27',
coffee_screen_size='17/18',
coffee_moisture_max=Decimal('11.50'),
coffee_defect_max=12,
coffee_cup_score_min=Decimal('82.00'))
self.assertEqual(
coffee_module.CoffeeLineMixin._coffee_attributes_name(line),
'Brazil | Arabica | Natural | Bourbon | 26/27 | 17/18 | '
'11.50 | 12 | 82.00')
def test_coffee_line_attributes_name_ignores_empty_quality_fields(self):
'empty coffee quality fields keep the standard product attributes name'
line = SimpleNamespace(
coffee_origin='',
coffee_type=None,
coffee_process=None,
coffee_variety=None,
coffee_crop_year=None,
coffee_screen_size=None,
coffee_moisture_max=None,
coffee_defect_max=None,
coffee_cup_score_min=None)
self.assertIsNone(
coffee_module.CoffeeLineMixin._coffee_attributes_name(line))
def test_purchase_line_attributes_name_prefers_coffee_quality(self):
'purchase line attributes name uses coffee quality when available'
line = SimpleNamespace(
_coffee_attributes_name=lambda: 'Brazil | Arabica',
product=None,
attributes=None)
self.assertEqual(
purchase_module.Line.on_change_with_attributes_name(line),
'Brazil | Arabica')
def test_sale_line_attributes_name_prefers_coffee_quality(self):
'sale line attributes name uses coffee quality when available'
line = SimpleNamespace(
_coffee_attributes_name=lambda: 'Brazil | Arabica',
product=None,
attributes=None)
self.assertEqual(
sale_module.SaleLine.on_change_with_attributes_name(line),
'Brazil | Arabica')
def test_coffee_lab_analysis_compliance_uses_line_targets(self):
'coffee lab analysis compliance checks sample line targets'
line = SimpleNamespace(
coffee_moisture_max=Decimal('12.50'),
coffee_defect_max=8,
coffee_cup_score_min=Decimal('82.00'))
sample = SimpleNamespace(_coffee_line=lambda: line)
analysis = SimpleNamespace(
state='received',
sample=sample,
moisture=Decimal('12.00'),
defect_count=6,
cup_score=Decimal('84.00'))
self.assertTrue(
coffee_module.CoffeeLabAnalysis.get_compliant(analysis))
def test_coffee_sample_quality_recommends_reject_on_failed_targets(self):
'coffee sample quality recommendation rejects failed target values'
line = SimpleNamespace(
coffee_moisture_max=Decimal('12.50'),
coffee_defect_max=8,
coffee_cup_score_min=Decimal('82.00'))
sample = SimpleNamespace(
purchase_line=line,
sale_line=None,
moisture=Decimal('13.00'),
defect_count=6,
lab_analyses=[],
cupping_lines=[],
get_cupping_average_score=lambda: Decimal('84.00'),
_coffee_line=lambda: line,
_latest_lab_analysis=lambda: None,
_quality_values=lambda: {
'moisture': Decimal('13.00'),
'defect_count': 6,
'cupping_score': Decimal('84.00'),
})
sample.get_quality_status = (
lambda: coffee_module.CoffeeSample.get_quality_status(sample))
self.assertEqual(
coffee_module.CoffeeSample.get_quality_status(sample),
'fail')
self.assertEqual(
coffee_module.CoffeeSample.get_recommended_decision(sample),
'reject')
def test_coffee_sample_default_expiry_date_uses_shelf_life(self):
'coffee sample expiry date defaults from request date and shelf life'
values = {
'requested_date': datetime.date(2026, 6, 29),
'shelf_life_days': 30,
}
coffee_module.CoffeeSample._set_default_expiry_date(
values, force=True)
self.assertEqual(
values['expiry_date'], datetime.date(2026, 7, 29))
@with_transaction()
def test_purchase_line_coffee_quality_status_uses_samples(self):
'purchase line coffee quality status follows linked samples'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.coffee_samples = [
Mock(state='approved', get_is_expired=Mock(return_value=False))]
self.assertEqual(
line.get_coffee_quality_status(), 'approved')
def test_purchase_line_syncs_packing_to_virtual_lot(self):
'purchase line packing updates the existing virtual lot'
package_unit = Mock(id=60)
lot = Mock(lot_type='virtual', lot_qt=None, lot_unit=None)
line = Mock(
lots=[lot],
coffee_packing_count=440,
coffee_packing_unit=package_unit)
lot_model = Mock()
pool = Mock()
pool.get.return_value = lot_model
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._sync_virtual_lot_packing(line)
self.assertEqual(lot.lot_qt, 440)
self.assertEqual(lot.lot_unit, package_unit)
lot_model.save.assert_called_once_with([lot])
def test_sale_line_syncs_packing_to_virtual_lot(self):
'sale line packing updates the existing virtual lot'
package_unit = Mock(id=90)
old_unit = Mock(id=60)
lot = Mock(lot_type='virtual', lot_qt=440, lot_unit=old_unit)
line = Mock(
lots=[lot],
coffee_packing_count=300,
coffee_packing_unit=package_unit)
lot_model = Mock()
pool = Mock()
pool.get.return_value = lot_model
with patch.object(sale_module, 'Pool', return_value=pool):
sale_module.SaleLine._sync_virtual_lot_packing(line)
self.assertEqual(lot.lot_qt, 300)
self.assertEqual(lot.lot_unit, package_unit)
lot_model.save.assert_called_once_with([lot])
def test_purchase_line_does_not_resave_aligned_virtual_lot_packing(self):
'aligned purchase packing does not resave the virtual lot'
package_unit = Mock(id=60)
lot = Mock(lot_type='virtual', lot_qt=440, lot_unit=Mock(id=60))
line = Mock(
lots=[lot],
coffee_packing_count=440,
coffee_packing_unit=package_unit)
lot_model = Mock()
pool = Mock()
pool.get.return_value = lot_model
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._sync_virtual_lot_packing(line)
lot_model.save.assert_not_called()
def test_purchase_auto_hedge_under_uses_floor_contract_count(self):
'purchase auto hedge under creates a short floor contract quantity'
unit = Mock()
price_index = Mock(id=7)
price_index.get_qt.side_effect = [Decimal('17'), Decimal('17')]
line = Mock(
id=12,
derivatives=[],
coffee_market_reference=price_index,
coffee_market_price=Decimal('300'),
quantity_theorical=Decimal('26.4'),
quantity=None,
unit=unit,
product=Mock(id=3),
purchase=Mock(id=4, party=Mock(id=5)))
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=False)]
derivative_model = Mock()
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 7, 16)
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.configuration': config_model,
'derivative.derivative': derivative_model,
'ir.date': date_model,
}[name]
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_called_once_with([{
'purchase': 4,
'line': 12,
'product': 3,
'party': 5,
'price_index': 7,
'nb_ct': 1,
'price': Decimal('300'),
'direction': 'short',
'trade_date': datetime.date(2026, 7, 16),
'open_qty': Decimal('17'),
}])
def test_sale_auto_hedge_over_uses_ceiling_contract_count(self):
'sale auto hedge over creates a long ceiling contract quantity'
unit = Mock()
price_index = Mock(id=8)
price_index.get_qt.side_effect = [Decimal('17'), Decimal('34')]
line = Mock(
id=22,
derivatives=[],
coffee_market_reference=price_index,
coffee_market_price=Decimal('301'),
quantity_theorical=Decimal('26.4'),
quantity=None,
unit=unit,
product=Mock(id=13),
sale=Mock(id=14, party=Mock(id=15)))
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=True)]
derivative_model = Mock()
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 7, 16)
pool = Mock()
pool.get.side_effect = lambda name: {
'sale.configuration': config_model,
'derivative.derivative': derivative_model,
'ir.date': date_model,
}[name]
with patch.object(sale_module, 'Pool', return_value=pool):
sale_module.SaleLine._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_called_once_with([{
'sale': 14,
'sale_line': 22,
'product': 13,
'party': 15,
'price_index': 8,
'nb_ct': 2,
'price': Decimal('301'),
'direction': 'long',
'trade_date': datetime.date(2026, 7, 16),
'open_qty': Decimal('34'),
}])
def test_auto_hedge_keeps_existing_manual_derivative(self):
'auto hedge does not create a duplicate when a derivative exists'
line = Mock(derivatives=[Mock()])
config_model = Mock()
config_model.search.return_value = [
Mock(auto_hedging=True, auto_hedging_over=True)]
derivative_model = Mock()
pool = Mock()
pool.get.side_effect = lambda name: {
'purchase.configuration': config_model,
'derivative.derivative': derivative_model,
}[name]
with patch.object(purchase_module, 'Pool', return_value=pool):
purchase_module.Line._ensure_auto_hedge_derivative(line)
derivative_model.create.assert_not_called()
def test_itsa_book_year_suffix_uses_april_fiscal_start(self):
'ITSA book year changes on April 1st'
self.assertEqual(
company_defaults._book_year_suffix(datetime.date(2026, 3, 31)),
'25')
self.assertEqual(
company_defaults._book_year_suffix(datetime.date(2026, 4, 1)),
'26')
@with_transaction()
def test_purchase_list_fields_use_first_trade_line(self):
'purchase list helper fields use the first real purchase line'
Purchase = Pool().get('purchase.purchase')
product = Mock(id=10)
period = Mock(id=20)
comment_line = Mock(type='comment')
trade_line = Mock(
type='line',
product=product,
price_type='basis',
del_period=period,
from_del=datetime.date(2026, 7, 1),
to_del=datetime.date(2026, 7, 31),
)
purchase = Purchase()
purchase.lines = [comment_line, trade_line]
self.assertEqual(purchase.get_first_line_product(None), 10)
self.assertEqual(purchase.get_first_line_price_type(None), 'basis')
self.assertEqual(purchase.get_first_line_del_period(None), 20)
self.assertEqual(
purchase.get_first_line_from_del(None),
datetime.date(2026, 7, 1))
self.assertEqual(
purchase.get_first_line_to_del(None),
datetime.date(2026, 7, 31))
@with_transaction()
def test_get_totals_without_rows(self):
'get_totals returns zeros when the query has no row'
Valuation = Pool().get('valuation.valuation')
cursor = Mock()
cursor.fetchone.return_value = None
connection = Mock()
connection.cursor.return_value = cursor
with patch(
'trytond.modules.purchase_trade.valuation.Transaction'
) as Transaction, patch.object(
Valuation, '__table__', return_value='valuation_valuation'):
Transaction.return_value.connection = connection
self.assertEqual(
Valuation.get_totals(), (Decimal(0), Decimal(0)))
@with_transaction()
def test_get_totals_without_previous_total(self):
'get_totals converts null variation to zero'
Valuation = Pool().get('valuation.valuation')
cursor = Mock()
cursor.fetchone.return_value = (Decimal('42.50'), None)
connection = Mock()
connection.cursor.return_value = cursor
with patch(
'trytond.modules.purchase_trade.valuation.Transaction'
) as Transaction, patch.object(
Valuation, '__table__', return_value='valuation_valuation'):
Transaction.return_value.connection = connection
self.assertEqual(
Valuation.get_totals(), (Decimal('42.50'), Decimal(0)))
def test_lotqt_plan_to_transport_splits_source_quantity(self):
'planning transport creates an isolated lot.qt split'
LotQt = lot_module.LotQt
source = Mock(
lot_p=Mock(id=1),
lot_s=Mock(id=2),
lot_quantity=Decimal('500'),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
)
source.has_shipment.return_value = False
source.is_planned_transport.return_value = False
values = {
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'truck',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'Early planning',
}
with patch.object(LotQt, 'save') as save, patch.object(
LotQt, 'create') as create:
LotQt.plan_to_transport(source, Decimal('125'), values)
self.assertEqual(source.lot_quantity, Decimal('375.00000'))
save.assert_called_once_with([source])
create.assert_called_once_with([{
'lot_p': 1,
'lot_s': 2,
'lot_quantity': Decimal('125.00000'),
'lot_unit': 3,
'lot_av': 'available',
'lot_status': 'forecast',
'lot_visible': True,
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'truck',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'Early planning',
}])
def test_lotqt_plan_to_transport_preserves_sale_only_sign(self):
'planning a sale-only lot.qt keeps the negative quantity direction'
LotQt = lot_module.LotQt
source = Mock(
lot_p=None,
lot_s=Mock(id=2),
lot_quantity=Decimal('-500'),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
)
source.has_shipment.return_value = False
source.is_planned_transport.return_value = False
with patch.object(LotQt, 'save'), patch.object(
LotQt, 'create') as create:
LotQt.plan_to_transport(
source, Decimal('125'),
{'planned_transport_type': 'vessel'})
self.assertEqual(source.lot_quantity, Decimal('-375.00000'))
self.assertEqual(
create.call_args[0][0][0]['lot_quantity'],
Decimal('-125.00000'))
self.assertEqual(create.call_args[0][0][0]['lot_p'], None)
self.assertEqual(create.call_args[0][0][0]['lot_s'], 2)
def test_lotqt_cancel_transport_plan_merges_matching_open_line(self):
'cancelling a transport plan returns quantity to the open lot.qt'
LotQt = lot_module.LotQt
planned = Mock(
lot_p=Mock(id=1),
lot_s=None,
lot_quantity=Decimal('125'),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
)
planned.has_shipment.return_value = False
planned.is_planned_transport.return_value = True
target = Mock(lot_quantity=Decimal('375'))
with patch.object(
LotQt, 'search', return_value=[target]) as search, patch.object(
LotQt, 'save') as save, patch.object(
LotQt, 'create') as create, patch.object(
LotQt, 'delete') as delete:
LotQt.cancel_transport_plan(planned)
search.assert_called_once()
self.assertEqual(target.lot_quantity, Decimal('500'))
save.assert_called_once_with([target])
create.assert_not_called()
delete.assert_called_once_with([planned])
def test_plan_transport_collects_affected_lines_before_cancel(self):
'plan transport wizard can keep affected lines before deleting lot.qt'
wizard = lot_module.LotPlanTransport()
purchase_line = Mock()
sale_line = Mock()
lotqt = Mock(
lot_p=Mock(line=purchase_line),
lot_s=Mock(sale_line=sale_line),
)
affected_lines = []
wizard._append_lotqt_affected_lines(lotqt, affected_lines)
self.assertEqual(affected_lines, [purchase_line, sale_line])
def test_plan_transport_defaults_multiple_lines(self):
'plan transport wizard opens one planning line per selected quantity'
wizard = lot_module.LotPlanTransport()
wizard.records = [
Mock(
id=10000001,
r_lot_quantity=Decimal('100'),
r_lot_matched=Decimal('0'),
r_lot_unit_line=Mock(id=3),
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20),
r_planned_transport_type='vessel',
r_planned_from_date=datetime.date(2026, 7, 1),
r_planned_to_date=datetime.date(2026, 7, 5),
r_planned_note='First leg'),
Mock(
id=10000002,
r_lot_quantity=Decimal('200'),
r_lot_matched=Decimal('0'),
r_lot_unit_line=Mock(id=4),
r_planned_from_location=None,
r_planned_to_location=None,
r_planned_transport_type=None,
r_planned_from_date=None,
r_planned_to_date=None,
r_planned_note=None),
]
values = wizard.default_plan(None)
self.assertEqual(len(values['lines']), 2)
self.assertEqual(values['lines'][0]['lotqt'], 1)
self.assertEqual(values['lines'][0]['quantity'], Decimal('100'))
self.assertEqual(values['lines'][0]['planned_from_location'], 10)
self.assertTrue(values['lines'][0]['cancel_plan'])
self.assertEqual(values['lines'][1]['lotqt'], 2)
self.assertEqual(values['lines'][1]['quantity'], Decimal('200'))
self.assertFalse(values['lines'][1]['cancel_plan'])
self.assertIsNone(values['default_lotqt'])
self.assertIsNone(values['default_unit'])
def test_plan_transport_defaults_single_line_for_new_rows(self):
'plan transport wizard gives new rows the current quantity line'
wizard = lot_module.LotPlanTransport()
wizard.records = [
Mock(
id=10002242,
r_lot_quantity=Decimal('1000'),
r_lot_matched=Decimal('0'),
r_lot_unit_line=Mock(id=3),
r_planned_from_location=None,
r_planned_to_location=None,
r_planned_transport_type=None,
r_planned_from_date=None,
r_planned_to_date=None,
r_planned_note=None),
]
values = wizard.default_plan(None)
self.assertEqual(values['default_lotqt'], 2242)
self.assertEqual(values['default_unit'], 3)
def test_plan_transport_line_defaults_from_context(self):
'new planning lines read their lot.qt and unit from the parent context'
context = {
'lot_plan_transport_default_lotqt': 2242,
'lot_plan_transport_default_unit': 3,
}
with patch.object(
lot_module, 'Transaction',
return_value=Mock(context=context)):
self.assertEqual(
lot_module.LotPlanTransportLine.default_lotqt(), 2242)
self.assertEqual(
lot_module.LotPlanTransportLine.default_unit(), 3)
def test_plan_transport_applies_each_planning_line(self):
'plan transport wizard applies each line independently'
wizard = lot_module.LotPlanTransport()
lotqt_a = Mock(
lot_p=Mock(line=Mock()),
lot_s=None)
lotqt_b = Mock(
lot_p=None,
lot_s=Mock(sale_line=Mock()))
wizard.plan = Mock(lines=[
Mock(
lotqt=lotqt_a,
cancel_plan=False,
quantity=Decimal('100'),
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
planned_transport_type='vessel',
planned_from_date=datetime.date(2026, 7, 1),
planned_to_date=datetime.date(2026, 7, 5),
planned_note='A'),
Mock(
lotqt=lotqt_b,
cancel_plan=False,
quantity=Decimal('200'),
planned_from_location=Mock(id=11),
planned_to_location=Mock(id=21),
planned_transport_type='truck',
planned_from_date=datetime.date(2026, 7, 6),
planned_to_date=datetime.date(2026, 7, 8),
planned_note='B'),
])
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_planning()
self.assertEqual(state, 'end')
LotQt.plan_to_transport.assert_any_call(lotqt_a, Decimal('100'), {
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'vessel',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'A',
})
LotQt.plan_to_transport.assert_any_call(lotqt_b, Decimal('200'), {
'planned_from_location': 11,
'planned_to_location': 21,
'planned_transport_type': 'truck',
'planned_from_date': datetime.date(2026, 7, 6),
'planned_to_date': datetime.date(2026, 7, 8),
'planned_note': 'B',
})
Lot.assert_lines_quantity_consistency.assert_called_once()
def test_lot_shipping_defaults_planned_locations(self):
'link to transport carries planned locations into shipment create'
wizard = lot_module.LotShipping()
wizard.records = [Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20),
)]
self.assertEqual(wizard.default_ship(None), {
'planned_from_location': 10,
'planned_to_location': 20,
})
def test_lot_shipping_defaults_purchase_locations_without_plan(self):
'link to transport falls back to purchase locations without a plan'
wizard = lot_module.LotShipping()
wizard.records = [Mock(
r_planned_from_location=None,
r_planned_to_location=None,
r_purchase=Mock(
from_location=Mock(id=10),
to_location=Mock(id=20)),
)]
self.assertEqual(wizard.default_ship(None), {
'planned_from_location': 10,
'planned_to_location': 20,
})
def test_lot_shipping_defaults_uniform_planned_locations(self):
'link to transport carries uniform planned locations from selection'
wizard = lot_module.LotShipping()
wizard.records = [
Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20)),
Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20)),
]
self.assertEqual(wizard.default_ship(None), {
'planned_from_location': 10,
'planned_to_location': 20,
})
def test_lot_shipping_ignores_mixed_planned_locations(self):
'link to transport does not force mixed planned locations'
wizard = lot_module.LotShipping()
wizard.records = [
Mock(
r_planned_from_location=Mock(id=10),
r_planned_to_location=Mock(id=20)),
Mock(
r_planned_from_location=Mock(id=11),
r_planned_to_location=Mock(id=21)),
]
self.assertEqual(wizard.default_ship(None), {})
def test_lot_shipping_link_creates_shipment_from_dialog_supplier(self):
'link to transport creates shipment in from dialog supplier'
wizard = lot_module.LotShipping()
supplier = Mock(id=30)
created_shipment = Mock(id=40)
ShipmentIn = Mock()
ShipmentIn.create.return_value = [created_shipment]
wizard.records = []
wizard.ship = Mock(
shipment='in',
shipment_in=None,
shipment_out=None,
shipment_internal=None,
create_new_shipment=True,
shipment_supplier=supplier,
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
)
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
pool = Mock()
pool.get.side_effect = [Lot, Mock(), Mock(), ShipmentIn]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_shipping()
self.assertEqual(state, 'end')
ShipmentIn.create.assert_called_once_with([{
'supplier': 30,
'from_location': 10,
'to_location': 20,
}])
self.assertEqual(wizard.ship.shipment_in, created_shipment)
Lot.assert_lines_quantity_consistency.assert_called_once_with([])
def test_lot_shipping_rejects_existing_and_new_shipment(self):
'link to transport rejects choosing existing shipment and create new'
wizard = lot_module.LotShipping()
wizard.ship = Mock(
shipment='in',
shipment_in=Mock(id=40),
shipment_out=None,
shipment_internal=None,
create_new_shipment=True,
shipment_supplier=Mock(id=30),
)
with self.assertRaises(UserError):
wizard.transition_shipping()
def test_lot_shipping_physical_without_move_creates_shipment_move(self):
'linking a physical lot to shipment creates the missing stock move'
wizard = lot_module.LotShipping()
shipment = Mock(id=40)
wizard.ship = Mock(
shipment='in',
shipment_in=shipment,
shipment_out=None,
shipment_internal=None,
create_new_shipment=False,
quantity=None,
)
record = Mock(
id=2014,
r_lot_type='physic',
)
wizard.records = [record]
move = Mock()
move.get_linked_transit_move.return_value = None
lot = Mock(
id=2014,
move=None,
line=Mock(),
sale_line=None,
)
lot.get_current_quantity_converted.return_value = Decimal('22')
lot.getVlot_p.return_value = Mock()
lot.getVlot_s.return_value = None
lot.create_shipment_move.return_value = move
Lot = Mock()
Lot.return_value = lot
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
Move = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_shipping()
self.assertEqual(state, 'end')
self.assertEqual(lot.lot_shipment_in, shipment)
lot.create_shipment_move.assert_called_once_with(
'stock.shipment.in,40')
self.assertEqual(move.shipment, 'stock.shipment.in,40')
Move.save.assert_called_once_with([move])
lot.updateVirtualPart.assert_called_once_with(
Decimal('-22'), 'stock.shipment.in,40', None)
Lot.save.assert_called()
Lot.assert_lines_quantity_consistency.assert_called_once_with([
lot.line])
def test_lot_shipping_open_quantity_uses_shared_transport_link(self):
'standalone link action keeps linking open lot.qt quantities'
wizard = lot_module.LotShipping()
shipment = Mock(id=40)
wizard.ship = Mock(
shipment='in',
shipment_in=shipment,
shipment_out=None,
shipment_internal=None,
create_new_shipment=False,
quantity=None,
)
wizard.records = [Mock(
id=10000007,
r_lot_shipment_in=None,
r_lot_quantity=Decimal('125'),
r_lot_matched=Decimal('0'),
)]
purchase_line = Mock()
source = Mock(
lot_p=Mock(line=purchase_line),
lot_s=None,
)
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock(return_value=source)
Move = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_shipping()
self.assertEqual(state, 'end')
LotQt.link_to_transport.assert_called_once_with(
source, Decimal('125.00000'), 'stock.shipment.in,40')
Lot.assert_lines_quantity_consistency.assert_called_once_with([
purchase_line])
def test_lot_unship_restores_planned_open_quantity(self):
'unlinking a planned scheduled lot.qt returns it to planned'
wizard = lot_module.LotUnship()
record = Mock(
id=10000007,
r_lot_type='virtual',
)
wizard.records = [record]
purchase_line = Mock()
sale_line = Mock()
lot = Mock(line=purchase_line)
sale_lot = Mock(sale_line=sale_line)
lqt = Mock(
lot_quantity=Decimal('40'),
lot_p=lot,
lot_s=sale_lot,
lot_shipment_in=Mock(),
lot_shipment_internal=None,
lot_shipment_out=None,
lot_shipment_origin='stock.shipment.in,12',
)
lqt.is_planned_transport.return_value = True
Lot = Mock()
Lot.return_value = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
LotQt.return_value = lqt
Move = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_start()
self.assertEqual(state, 'end')
lot.updateVirtualPart.assert_not_called()
LotQt.restore_planned_open_quantity.assert_called_once_with(
lqt, Decimal('40'))
LotQt.delete.assert_called_once_with([lqt])
lot.createVirtualPart.assert_not_called()
Lot.assert_lines_quantity_consistency.assert_called_once_with([
purchase_line, sale_line])
def test_lot_unship_deletes_zero_scheduled_lotqt_without_restore(self):
'unlinking a residual zero scheduled lot.qt only cleans the shipment tab'
wizard = lot_module.LotUnship()
wizard.records = [Mock(id=10000007, r_lot_type='virtual')]
purchase_line = Mock()
sale_line = Mock()
lot = Mock(line=purchase_line)
sale_lot = Mock(sale_line=sale_line)
lqt = Mock(
lot_quantity=Decimal('0'),
lot_p=lot,
lot_s=sale_lot,
lot_shipment_in=Mock(),
lot_shipment_internal=None,
lot_shipment_out=None,
)
lqt.is_planned_transport.return_value = True
Lot = Mock()
Lot.return_value = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
LotQt = Mock()
LotQt.return_value = lqt
Move = Mock()
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_start()
self.assertEqual(state, 'end')
LotQt.restore_planned_open_quantity.assert_not_called()
lot.updateVirtualPart.assert_not_called()
lot.createVirtualPart.assert_not_called()
LotQt.delete.assert_called_once_with([lqt])
Lot.assert_lines_quantity_consistency.assert_called_once_with([
purchase_line, sale_line])
def test_add_physical_lots_requires_linked_transport(self):
'physical lots can only be added from a scheduled quantity'
LotQt = Pool().get('lot.qt')
lqt = Mock()
lqt.has_shipment.return_value = False
with self.assertRaises(UserError):
LotQt.add_physical_lots(lqt, [])
def test_add_physical_lot_copies_planned_transport_memory(self):
'physical lots keep planned transport fields from lot.qt'
LotQt = Pool().get('lot.qt')
lqt = LotQt()
planned_from = Mock()
planned_to = Mock()
purchase_line = Mock(product=Mock(), fees=[])
lqt.lot_p = Mock(line=purchase_line)
lqt.lot_s = None
lqt.lot_shipment_in = Mock(fees=[])
lqt.lot_shipment_internal = None
lqt.lot_shipment_out = None
lqt.planned_from_location = planned_from
lqt.planned_to_location = planned_to
lqt.planned_transport_type = 'truck'
lqt.planned_from_date = datetime.date(2026, 6, 15)
lqt.planned_to_date = datetime.date(2026, 6, 20)
lqt.planned_note = 'Gate 4'
physical_input = Mock(
lot_qt=Decimal('1'),
lot_unit=Mock(),
lot_unit_line=Mock(),
lot_quantity=Decimal('40'),
lot_gross_quantity=Decimal('41'),
lot_premium=Decimal('0'),
lot_chunk_key=None,
)
newlot = Mock()
newlot.line = purchase_line
newlot.sale_line = None
newlot.set_current_quantity = Mock()
newlot.get_current_quantity_converted.return_value = Decimal('40')
Lot = Mock()
Lot.return_value = newlot
FeeLots = Mock()
Purchase = Mock()
pool = Mock()
pool.get.side_effect = [Lot, FeeLots, Purchase]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
lot, _ = lqt.add_physical_lot(physical_input)
self.assertIs(lot.planned_from_location, planned_from)
self.assertIs(lot.planned_to_location, planned_to)
self.assertEqual(lot.planned_transport_type, 'truck')
self.assertEqual(lot.planned_from_date, datetime.date(2026, 6, 15))
self.assertEqual(lot.planned_to_date, datetime.date(2026, 6, 20))
self.assertEqual(lot.planned_note, 'Gate 4')
def test_remove_physical_lot_restores_planned_scheduled_quantity(self):
'removing a planned physical lot restores the planned scheduled lot.qt'
wizard = lot_module.LotRemove()
wizard.records = [Mock(id=2014)]
quantity = Decimal('40')
lot_s = Mock()
lot = Mock(
move=None,
lot_shipment_origin='stock.shipment.in,12',
lot_shipment_in=None,
lot_shipment_internal=None,
lot_shipment_out=None,
)
lot.get_current_quantity_converted.return_value = quantity
lot.getVlot_s.return_value = lot_s
lot.sale_line = Mock()
lot.is_planned_transport.return_value = True
Lot = Mock()
Lot.return_value = lot
LotQt = Mock()
Move = Mock()
Warning = Mock()
Warning.check.return_value = False
pool = Mock()
pool.get.side_effect = [Lot, LotQt, Move, Warning]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_start()
self.assertEqual(state, 'end')
LotQt.restore_planned_scheduled_quantity.assert_called_once_with(
lot, quantity, lot_s, 'stock.shipment.in,12')
lot.updateVirtualPart.assert_not_called()
Lot.delete.assert_called_once_with([lot])
def test_lot_shipping_scheduled_lotqt_keeps_planned_locations(self):
'scheduled lot.qt keeps the planned from and to locations'
LotQt = Pool().get('lot.qt')
linked = Mock()
lqt = Mock(
lot_quantity=Decimal('125'),
lot_p=Mock(id=1),
lot_s=Mock(id=2),
lot_unit=Mock(id=3),
lot_av='available',
lot_status='forecast',
lot_visible=True,
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
planned_transport_type='vessel',
planned_from_date=datetime.date(2026, 7, 1),
planned_to_date=datetime.date(2026, 7, 5),
planned_note='POL to POD',
)
lqt.is_planned_transport.return_value = True
with patch.object(
LotQt, 'create', return_value=[linked]) as create, \
patch.object(LotQt, 'save') as save:
result = LotQt.link_to_transport(
lqt, Decimal('125'), 'stock.shipment.in,40')
create.assert_called_once_with([{
'lot_p': 1,
'lot_s': 2,
'lot_quantity': Decimal('125'),
'lot_unit': 3,
'lot_av': 'available',
'lot_status': 'forecast',
'lot_visible': True,
'planned_from_location': 10,
'planned_to_location': 20,
'planned_transport_type': 'vessel',
'planned_from_date': datetime.date(2026, 7, 1),
'planned_to_date': datetime.date(2026, 7, 5),
'planned_note': 'POL to POD',
'lot_shipment_in': 40,
}])
save.assert_called_once_with([lqt])
self.assertEqual(lqt.lot_quantity, 0)
self.assertIs(result, linked)
def test_add_physical_lot_defaults_transport_from_purchase(self):
'add physical lot proposes shipment creation from purchase values'
wizard = lot_module.LotAdding()
purchase = Mock(
party=Mock(id=30),
from_location=Mock(id=10),
to_location=Mock(id=20),
)
lqt = Mock(
lot_p=Mock(line=Mock(
purchase=purchase,
unit=Mock(id=3))),
lot_quantity=Decimal('125'),
lot_shipment_in=None,
lot_shipment_internal=None,
lot_shipment_out=None,
planned_from_location=None,
planned_to_location=None,
)
lqt.lot_p.lot_unit_line = Mock(id=3)
LotQt = Mock(return_value=lqt)
pool = Mock()
pool.get.side_effect = [Mock(), LotQt]
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool), patch(
'trytond.modules.purchase_trade.lot.Transaction'
) as TransactionMock:
TransactionMock.return_value.context = {
'active_ids': [10000007]}
values = wizard.default_add(None)
self.assertTrue(values['transport_missing'])
self.assertIsNone(values['shipment_supplier'])
self.assertEqual(values['transport_from_location'], 10)
self.assertEqual(values['transport_to_location'], 20)
def test_add_physical_lot_links_existing_shipment_before_add(self):
'add physical lot can link an existing shipment in one transaction'
wizard = lot_module.LotAdding()
shipment = Mock(id=40)
source = Mock(lot_quantity=Decimal('125'))
source.has_shipment.return_value = False
linked = Mock()
LotQt = Mock(return_value=source)
LotQt.link_to_transport.return_value = linked
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
pool = Mock()
pool.get.side_effect = [LotQt, Lot]
wizard.records = [Mock(id=10000007)]
wizard.add = Mock(
shipment_in=shipment,
create_new_shipment=False,
lots=[Mock()],
unlink_remainder=True,
)
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_adding()
self.assertEqual(state, 'end')
LotQt.link_to_transport.assert_called_once_with(
source, Decimal('125'), 'stock.shipment.in,40')
LotQt.add_physical_lots.assert_called_once_with(
linked, wizard.add.lots, True)
def test_add_physical_lot_creates_shipment_with_optional_bl(self):
'add physical lot creates and links a shipment with optional BL data'
wizard = lot_module.LotAdding()
shipment = Mock(id=40)
source = Mock(lot_quantity=Decimal('125'))
source.has_shipment.return_value = False
linked = Mock()
LotQt = Mock(return_value=source)
LotQt.link_to_transport.return_value = linked
ShipmentIn = Mock()
ShipmentIn.create.return_value = [shipment]
Lot = Mock()
Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
pool = Mock()
pool.get.side_effect = [LotQt, ShipmentIn, Lot]
wizard.records = [Mock(id=10000007)]
wizard.add = Mock(
shipment_in=None,
create_new_shipment=True,
shipment_supplier=Mock(id=30),
transport_from_location=Mock(id=10),
transport_to_location=Mock(id=20),
shipment_bl_number='BL-2026-01',
shipment_bl_date=datetime.date(2026, 6, 22),
lots=[Mock()],
unlink_remainder=True,
)
with patch('trytond.modules.purchase_trade.lot.Pool',
return_value=pool):
state = wizard.transition_adding()
self.assertEqual(state, 'end')
ShipmentIn.create.assert_called_once_with([{
'supplier': 30,
'from_location': 10,
'to_location': 20,
'bl_number': 'BL-2026-01',
'bl_date': datetime.date(2026, 6, 22),
}])
LotQt.link_to_transport.assert_called_once_with(
source, Decimal('125'), 'stock.shipment.in,40')
LotQt.add_physical_lots.assert_called_once_with(
linked, wizard.add.lots, True)
@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'))
strategy.components[0].price_index.get_price.assert_called_once_with(
'2026-03-29',
line.unit,
strategy.currency,
relative_last=True)
def test_mtm_scenario_resolves_fixed_valuation_date(self):
'mtm scenario can force an explicit valuation date'
scenario = pricing_module.MtmScenario()
scenario.valuation_date_type = 'fixed'
scenario.valuation_date = datetime.date(2026, 6, 18)
self.assertEqual(
scenario.get_valuation_date(),
datetime.date(2026, 6, 18))
def test_itsa_mtm_scenario_defaults_calendar(self):
'ITSA mtm scenarios default to the sulphuric acid calendar'
with patch('trytond.modules.purchase_trade.pricing.is_itsa_company',
return_value=True), patch(
'trytond.modules.purchase_trade.pricing.'
'default_itsa_price_calendar',
return_value=17):
self.assertEqual(pricing_module.MtmScenario.default_calendar(), 17)
def test_itsa_mtm_component_defaults(self):
'ITSA mtm components default market price, ratio and USD currency'
with patch('trytond.modules.purchase_trade.pricing.is_itsa_company',
return_value=True), patch(
'trytond.modules.purchase_trade.pricing.'
'default_itsa_price_fix_type',
return_value=21), patch(
'trytond.modules.purchase_trade.pricing.default_itsa_currency',
return_value=840):
self.assertEqual(pricing_module.Mtm.default_fix_type(), 21)
self.assertEqual(
pricing_module.Mtm.default_ratio(), Decimal('100'))
self.assertEqual(pricing_module.Mtm.default_currency(), 840)
def test_itsa_pricing_component_defaults(self):
'ITSA pricing definitions default market price, ratio, USD and calendar'
with patch('trytond.modules.purchase_trade.pricing.is_itsa_company',
return_value=True), patch(
'trytond.modules.purchase_trade.pricing.'
'default_itsa_price_fix_type',
return_value=21), patch(
'trytond.modules.purchase_trade.pricing.'
'default_itsa_price_calendar',
return_value=17), patch(
'trytond.modules.purchase_trade.pricing.default_itsa_currency',
return_value=840):
self.assertEqual(pricing_module.Component.default_fix_type(), 21)
self.assertEqual(
pricing_module.Component.default_ratio(), Decimal('100'))
self.assertEqual(
pricing_module.Component.default_fixed_currency(), 840)
self.assertEqual(pricing_module.Component.default_calendar(), 17)
def test_mtm_scenario_resolves_relative_valuation_dates(self):
'mtm scenario can resolve relative valuation dates'
scenario = pricing_module.MtmScenario()
Date = Mock()
Date.today.return_value = datetime.date(2026, 2, 12)
with patch(
'trytond.modules.purchase_trade.pricing.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = Date
scenario.valuation_date_type = 'today'
self.assertEqual(
scenario.get_valuation_date(),
datetime.date(2026, 2, 12))
scenario.valuation_date_type = 'month_end'
self.assertEqual(
scenario.get_valuation_date(),
datetime.date(2026, 2, 28))
scenario.valuation_date_type = 'next_month_end'
self.assertEqual(
scenario.get_valuation_date(),
datetime.date(2026, 3, 31))
@with_transaction()
def test_get_mtm_uses_resolved_scenario_date(self):
'get_mtm uses the scenario resolved valuation date'
Strategy = Pool().get('mtm.strategy')
strategy = Strategy()
scenario = pricing_module.MtmScenario()
scenario.valuation_date_type = 'month_end'
scenario.use_last_price = True
strategy.scenario = scenario
strategy.currency = Mock()
price_index = Mock(get_price=Mock(return_value=Decimal('100')))
strategy.components = [Mock(
price_source_type='curve',
price_index=price_index,
price_matrix=None,
ratio=Decimal('100'),
)]
line = Mock(unit=Mock())
Date = Mock()
Date.today.return_value = datetime.date(2026, 2, 12)
with patch(
'trytond.modules.purchase_trade.pricing.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = Date
self.assertEqual(
strategy.get_mtm(line, Decimal('2')),
Decimal('200.00'))
price_index.get_price.assert_called_once_with(
datetime.date(2026, 2, 28),
line.unit,
strategy.currency,
relative_last=True)
@with_transaction()
def test_get_mtm_uses_fixed_pricing_component(self):
'get_mtm values fixed components like a constant price curve'
Strategy = Pool().get('mtm.strategy')
strategy = Strategy()
strategy.scenario = Mock(
valuation_date='2026-03-29',
use_last_price=True,
)
strategy.currency = Mock()
strategy.components = [Mock(
price_source_type='fixed',
price_index=None,
price_matrix=None,
get_price=Mock(return_value=Decimal('75')),
ratio=Decimal('100'),
)]
line = Mock(unit=Mock())
self.assertEqual(
strategy.get_mtm(line, Decimal('3')),
Decimal('225.00'))
@with_transaction()
def test_signed_strategy_mtm_follows_valuation_direction(self):
'strategy MTM follows purchase/sale valuation sign'
Valuation = Pool().get('valuation.valuation')
strategy = Mock(get_mtm=Mock(return_value=Decimal('349167.53')))
line = Mock()
self.assertEqual(
Valuation._signed_strategy_mtm(
{'type': 'pur. priced', 'amount': Decimal('-190686.15'),
'quantity': Decimal('4237.47')},
strategy, line),
Decimal('-349167.53'))
self.assertEqual(
Valuation._signed_strategy_mtm(
{'type': 'sale priced', 'amount': Decimal('349167.53'),
'quantity': Decimal('4237.47')},
strategy, line),
Decimal('349167.53'))
self.assertEqual(
Valuation._signed_strategy_mtm(
{'type': 'pur. priced', 'amount': Decimal('0'),
'quantity': Decimal('0')},
strategy, line),
Decimal('-349167.53'))
@with_transaction()
def test_strategy_mtm_lines_are_separate_from_realized_price(self):
'strategy MTM creates separate curve lines and preserves realized line'
Valuation = Pool().get('valuation.valuation')
valuation_date = datetime.date(2026, 6, 5)
previous_date = datetime.date(2026, 6, 4)
unit = Mock()
currency = Mock()
line = Mock(unit=unit)
curve_a = Mock(id=10)
curve_a.get_price.side_effect = [Decimal('100'), Decimal('90')]
curve_b = Mock(id=20)
curve_b.get_price.side_effect = [Decimal('50'), Decimal('45')]
strategy = Mock(
scenario=Mock(
valuation_date=valuation_date,
use_last_price=False),
currency=currency,
components=[
Mock(
price_source_type='curve',
price_index=curve_a,
ratio=Decimal('60')),
Mock(
price_source_type='curve',
price_index=curve_b,
ratio=Decimal('40')),
])
line.mtm = [strategy]
values = {
'type': 'pur. priced',
'price': Decimal('10'),
'amount': Decimal('-100'),
'base_amount': Decimal('-100'),
'quantity': Decimal('10'),
}
target = []
price_value = Mock()
price_value.search.return_value = [Mock(price_date=previous_date)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.return_value = price_value
Valuation._append_pnl_values(target, values, line)
self.assertEqual(len(target), 3)
self.assertEqual(target[0], values)
self.assertEqual(target[0]['pnl'], Decimal('-100.00'))
self.assertEqual(target[1]['type'], 'pur. mtm')
self.assertEqual(target[1]['mtm_curve'], curve_a.id)
self.assertEqual(target[1]['price'], None)
self.assertEqual(target[1]['amount'], Decimal('0'))
self.assertEqual(target[1]['quantity'], Decimal('10'))
self.assertEqual(target[1]['mtm_price'], Decimal('100.0000'))
self.assertEqual(target[1]['mtm_price_prev'], Decimal('90.0000'))
self.assertEqual(target[1]['amount_prev'], Decimal('-540.00'))
self.assertEqual(target[1]['mtm'], Decimal('-600.00'))
self.assertEqual(target[1]['pnl'], Decimal('600.00'))
self.assertEqual(target[2]['type'], 'pur. mtm')
self.assertEqual(target[2]['mtm_curve'], curve_b.id)
self.assertEqual(target[2]['price'], None)
self.assertEqual(target[2]['amount'], Decimal('0'))
self.assertEqual(target[2]['quantity'], Decimal('10'))
self.assertEqual(target[2]['mtm_price'], Decimal('50.0000'))
self.assertEqual(target[2]['mtm_price_prev'], Decimal('45.0000'))
self.assertEqual(target[2]['amount_prev'], Decimal('-180.00'))
self.assertEqual(target[2]['mtm'], Decimal('-200.00'))
self.assertEqual(target[2]['pnl'], Decimal('200.00'))
@with_transaction()
def test_sale_strategy_mtm_lines_use_sale_type(self):
'sale strategy MTM lines are labelled as sale MTM'
Valuation = Pool().get('valuation.valuation')
line = Mock(unit=Mock())
strategy = Mock(
scenario=Mock(valuation_date=datetime.date(2026, 6, 5)),
currency=None,
components=[],
get_mtm=Mock(return_value=Decimal('250')))
line.mtm = [strategy]
values = {
'type': 'sale priced',
'amount': Decimal('100'),
'base_amount': Decimal('100'),
'quantity': Decimal('10'),
}
target = []
Valuation._append_pnl_values(target, values, line)
self.assertEqual(target[1]['type'], 'sale mtm')
self.assertEqual(target[1]['mtm'], Decimal('250'))
self.assertEqual(target[1]['pnl'], Decimal('-250.00'))
def test_pnl_converts_strategy_mtm_to_base_currency(self):
'valuation PnL compares base amount with MTM in base currency'
Valuation = valuation_module.Valuation
source_currency = Mock(id=1)
base_currency = Mock(id=2)
strategy = Mock(currency=source_currency, scenario=None)
values = {
'base_amount': Decimal('100'),
'base_currency': base_currency,
}
Currency = Mock()
Currency.compute.return_value = Decimal('90')
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.return_value = Currency
pnl = Valuation._pnl_amount(
values, strategy=strategy, mtm_amount=Decimal('120'))
Currency.compute.assert_called_once_with(
source_currency, Decimal('120'), base_currency)
self.assertEqual(pnl, Decimal('10.00'))
def test_purchase_pnl_uses_partial_lotqt_match_quantity(self):
'open matched purchase and sale pnl use the matched lot.qt quantity'
Valuation = valuation_module.Valuation
unit = Mock(id=1)
currency = Mock(id=2)
company = Mock(currency=2)
purchase = Mock(
id=10, currency=currency, company=company, party=Mock(id=11))
purchase_line = Mock(
id=20, price_type='priced', mtm=[], purchase=purchase,
product=Mock(id=21), unit=unit, lots=[])
purchase_lot = Mock(
id=30, lot_type='virtual', lot_price=Decimal('45'),
sale_line=None)
purchase_lot.get_current_quantity_converted.return_value = (
Decimal('1000'))
sale = Mock(
id=40, currency=currency, company=company, party=Mock(id=41))
sale_line = Mock(
id=50, price_type='priced', mtm=[], sale=sale,
product=Mock(id=51), unit=unit)
sale_lot = Mock(
id=60, lot_type='virtual', lot_price_sale=Decimal('100'),
sale_line=sale_line)
sale_lot.get_current_quantity_converted.return_value = Decimal('2000')
purchase_line.lots = [purchase_lot]
shipment = Mock(id=70)
lotqt = Mock(
lot_p=purchase_lot, lot_s=sale_lot,
lot_quantity=Decimal('500'), lot_unit=unit,
lot_shipment_in=shipment)
lotqt_model = Mock()
lotqt_model.search.return_value = [lotqt]
pool = Mock()
pool.get.return_value = lotqt_model
with patch.object(valuation_module, 'Pool', return_value=pool):
lines = Valuation.create_pnl_price_from_line(purchase_line)
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0]['type'], 'pur. priced')
self.assertEqual(lines[0]['quantity'], Decimal('500.00000'))
self.assertEqual(lines[0]['shipment_in'], shipment.id)
self.assertEqual(lines[0]['amount'], Decimal('-22500.00'))
self.assertEqual(lines[1]['type'], 'sale priced')
self.assertEqual(lines[1]['quantity'], Decimal('500.00000'))
self.assertEqual(lines[1]['shipment_in'], shipment.id)
self.assertEqual(lines[1]['amount'], Decimal('50000.00'))
@with_transaction()
def test_purchase_line_charter_conditions_inherit_header_when_empty(self):
'purchase line uses header charter conditions when it has no line terms'
Line = Pool().get('purchase.line')
line_condition = Mock(id=2)
header_condition = Mock(id=1)
line = Line()
line.charter_conditions = []
line.purchase = Mock(charter_conditions=[header_condition])
self.assertEqual(
line.get_effective_charter_conditions(), [header_condition])
line.charter_conditions = [line_condition]
self.assertEqual(
line.get_effective_charter_conditions(), [line_condition])
@with_transaction()
def test_sale_line_charter_conditions_inherit_header_when_empty(self):
'sale line uses header charter conditions when it has no line terms'
Line = Pool().get('sale.line')
line_condition = Mock(id=2)
header_condition = Mock(id=1)
line = Line()
line.charter_conditions = []
line.sale = Mock(charter_conditions=[header_condition])
self.assertEqual(
line.get_effective_charter_conditions(), [header_condition])
line.charter_conditions = [line_condition]
self.assertEqual(
line.get_effective_charter_conditions(), [line_condition])
@with_transaction()
def test_charter_condition_rate_type_sets_default_basis(self):
'charter condition rate copies the dynamic type default basis'
Rate = Pool().get('charter.condition.rate')
rate = Rate()
rate.rate_type = Mock(default_basis='per_day')
rate.on_change_rate_type()
self.assertEqual(rate.basis, 'per_day')
def test_charter_condition_rate_basis_includes_pumping_unit(self):
'charter condition rates expose a clear pumping throughput unit'
self.assertIn(('mt_per_hour', 'MT/hour'), stock_module.RATE_BASIS)
@with_transaction()
def test_charter_condition_role_defaults_from_context(self):
'charter condition role defaults to the business source context'
Condition = Pool().get('charter.condition')
with Transaction().set_context(charter_condition_role='sale'):
self.assertEqual(Condition.default_party_role(), 'customer')
condition = Condition()
self.assertEqual(
condition.get_party_roles(),
[(None, ''), ('customer', 'Customer')])
with Transaction().set_context(charter_condition_role='purchase'):
self.assertEqual(Condition.default_party_role(), 'supplier')
condition = Condition()
self.assertEqual(
condition.get_party_roles(),
[(None, ''), ('supplier', 'Supplier')])
with Transaction().set_context(charter_condition_role='charter_party'):
self.assertEqual(Condition.default_party_role(), 'owner')
condition = Condition()
roles = {role for role, _ in condition.get_party_roles()}
self.assertNotIn('supplier', roles)
self.assertNotIn('customer', roles)
@with_transaction()
def test_charter_condition_role_rejects_invalid_source_role(self):
'charter condition role is constrained by its source'
Condition = Pool().get('charter.condition')
condition = Condition()
condition.name = 'Invalid supplier condition on sale'
condition.party_role = 'supplier'
with Transaction().set_context(charter_condition_role='sale'):
with self.assertRaises(UserError):
Condition.validate([condition])
@with_transaction()
def test_sof_calculation_reads_applied_condition_rates(self):
'sof demurrage calculation uses the selected charter condition rates'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_allowed=None,
laytime_start_offset=None,
laytime_start_offset_unit=None,
turn_time=Decimal('6'),
turn_time_unit='hours',
rates=[
Mock(rate=Decimal('300'), rate_type=Mock(category='pumping')),
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
],
)
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof._applied_turn_delta(), datetime.timedelta(hours=6))
self.assertEqual(
sof._applied_laytime_allowed(Decimal('600')), 2.0)
self.assertEqual(
sof._applied_compensation_amount(), Decimal('-12000.00'))
self.assertEqual(sof.compensation_type, 'demurrage')
@with_transaction()
def test_sof_calculation_rate_overrides_win_over_condition_rates(self):
'sof calculation rate overrides are used before condition rates'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_allowed=None,
rates=[
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
Mock(rate=Decimal('100'), rate_type=Mock(category='pumping')),
])
sof.demurrage_rate_override = Decimal('36000')
sof.pumping_rate_override = Decimal('200')
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof._applied_laytime_allowed(Decimal('1000')), 5.0)
self.assertEqual(
sof._applied_compensation_amount(), Decimal('-18000.00'))
self.assertEqual(sof.compensation_type, 'demurrage')
self.assertEqual(
sof.get_calculation_info('calculation_demurrage_rate'),
'36000 / day')
@with_transaction()
def test_sof_available_conditions_include_shipment_contracts(self):
'sof applied condition choices include owner, purchase and sale terms'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.charter_party = None
sof.shipment = Mock(
get_owner_charter_conditions=Mock(return_value=[1]),
get_purchase_charter_conditions=Mock(return_value=[2]),
get_sale_charter_conditions=Mock(return_value=[3]))
self.assertEqual(sof.get_applied_conditions(), [1, 2, 3])
@with_transaction()
def test_sof_default_applied_condition_can_use_purchase_terms(self):
'sof default applied condition can come from purchase without charter'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.charter_party = None
sof.shipment = Mock(
charter_party=None,
get_owner_charter_conditions=Mock(return_value=[]),
get_purchase_charter_conditions=Mock(return_value=[12]),
get_sale_charter_conditions=Mock(return_value=[]))
sof.on_change_shipment()
self.assertEqual(sof.applied_condition, 12)
@with_transaction()
def test_sof_laytime_start_reads_structured_condition_rule(self):
'sof laytime start reads the structured event and offset'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_start_event='notice_of_readiness',
laytime_start_offset=Decimal('6'),
laytime_start_offset_unit='hours',
turn_time=None)
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
self.assertEqual(
sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 8, 0))
@with_transaction()
def test_sof_laytime_start_uses_earliest_candidate(self):
'sof laytime start can use the earliest of multiple candidates'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = SimpleNamespace(
laytime_start_rule='earliest_of',
laytime_start_rules=[
SimpleNamespace(
sequence=10,
event='notice_of_readiness',
offset=Decimal('6'),
offset_unit='hours'),
SimpleNamespace(
sequence=20,
event='all_fast',
offset=None,
offset_unit='hours'),
])
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
sof.all_fast = datetime.datetime(2026, 6, 1, 7, 0)
self.assertEqual(
sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 7, 0))
self.assertEqual(
sof.get_calculation_info('calculation_start_event'),
'Earliest Of')
self.assertEqual(
sof.get_calculation_info('calculation_start_offset'),
'Notice of Readiness + 6 hours; All Fast')
@with_transaction()
def test_sof_laytime_end_reads_structured_condition_rule(self):
'sof laytime end reads the structured end event'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(laytime_end_event='documents_on_board')
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
sof.documents_on_board = datetime.datetime(2026, 6, 1, 14, 0)
self.assertEqual(
sof._applied_laytime_end(),
datetime.datetime(2026, 6, 1, 14, 0))
@with_transaction()
def test_sof_additional_quantity_is_added_to_calculation_quantity(self):
'sof additional quantity is added to the quantity used for calculation'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.additional_quantity = Decimal('25')
self.assertEqual(
sof._calculation_quantity(Decimal('100')),
Decimal('125'))
@with_transaction()
def test_sof_laytime_ignores_free_text_clauses(self):
'sof laytime calculation ignores contractual free text fields'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_start_event='arrival',
laytime_start_offset=Decimal('1'),
laytime_start_offset_unit='hours',
laytime_end_event='end_pumping',
laytime_start='NOR + 6 hours',
laytime_end='Hoses disconnected',
turn_time=None)
sof.arrival_time = datetime.datetime(2026, 6, 1, 3, 0)
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
self.assertEqual(
sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 4, 0))
self.assertEqual(
sof._applied_laytime_end(),
datetime.datetime(2026, 6, 1, 12, 0))
@with_transaction()
def test_sof_calculation_info_explains_structured_inputs(self):
'sof calculation info displays the structured calculation inputs'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
rec_name='Discharge',
name='Discharge',
party_role='owner',
responsibility='ours',
laytime_allowed=Decimal('72'),
laytime_unit='hours',
laytime_start_event='notice_of_readiness',
laytime_start_offset=Decimal('6'),
laytime_start_offset_unit='hours',
laytime_end_event='hoses_disconnected',
laytime_start='Arrival + 1 day',
laytime_end='End pumping',
rates=[
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
Mock(rate=Decimal('12000'), rate_type=Mock(category='despatch')),
])
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 4, 10, 0)
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof.get_calculation_info('calculation_condition'), 'Discharge')
self.assertEqual(
sof.get_calculation_info('calculation_start_event'),
'Notice of Readiness')
self.assertEqual(
sof.get_calculation_info('calculation_start_event_time'),
'01/06/2026 02:00')
self.assertEqual(
sof.get_calculation_info('calculation_start_offset'), '6 hours')
self.assertEqual(
sof.get_calculation_info('calculation_end_event'),
'Hoses Disconnected')
self.assertEqual(
sof.get_calculation_info('calculation_end_event_time'),
'04/06/2026 10:00')
self.assertEqual(
sof.get_calculation_info('calculation_rate_used'),
'Demurrage: 24000 / day')
@with_transaction()
def test_sof_header_date_time_values_sync_to_datetime(self):
'sof header date and time fields build the technical datetime value'
Sof = Pool().get('sof.statement')
values = Sof._sync_datetime_values({
'notice_of_readiness_date': datetime.date(2026, 7, 6),
'notice_of_readiness_hour': datetime.time(8, 0),
'all_fast_date': datetime.date(2026, 7, 6),
'all_fast_hour': datetime.time(10, 30),
'documents_on_board_date': datetime.date(2026, 7, 6),
'documents_on_board_hour': datetime.time(12, 0),
})
self.assertEqual(
values['notice_of_readiness_time'],
datetime.datetime(2026, 7, 6, 8, 0))
self.assertEqual(
values['all_fast'],
datetime.datetime(2026, 7, 6, 10, 30))
self.assertEqual(
values['documents_on_board'],
datetime.datetime(2026, 7, 6, 12, 0))
@with_transaction()
def test_sof_event_date_time_values_sync_to_datetime(self):
'sof event date and time fields build the technical datetime values'
Event = Pool().get('sof.event')
values = Event._sync_datetime_values({
'start_date': datetime.date(2026, 1, 1),
'start_time': datetime.time(10, 0),
'end_date': datetime.date(2026, 1, 1),
'end_time': datetime.time(12, 0),
})
self.assertEqual(
values['start'], datetime.datetime(2026, 1, 1, 10, 0))
self.assertEqual(
values['end'], datetime.datetime(2026, 1, 1, 12, 0))
def test_contract_clause_variables_and_rendering_use_contract_context(self):
'contract clauses expose placeholders and render from purchase context'
clause = purchase_module.ContractClause()
clause.name = 'Payment'
clause.direction = 'both'
clause.text = (
'Buyer pays [party] under [incoterm] for [product] '
'contract [Contract_Number].')
self.assertEqual(
clause.get_variables('variables'),
'Contract_Number, incoterm, party, product')
selection = purchase_module.ContractClauseSelection()
selection.clause = clause
selection.purchase = Mock(
reference='PUR-001',
party=Mock(rec_name='Supplier Ltd'),
company=Mock(rec_name='Company SA'),
currency=Mock(rec_name='USD'),
incoterm=Mock(code='FOB'),
incoterm_location=None,
payment_term=None,
product_origin='',
lines=[
Mock(type='line', product=Mock(rec_name='Steel'), quantity=10,
unit=Mock(rec_name='MT')),
],
)
self.assertEqual(
selection.get_rendered_text('rendered_text'),
'Buyer pays Supplier Ltd under FOB for Steel contract PUR-001.')
@with_transaction()
def test_add_physical_lot_defaults_hidden_premium_and_chunk_key(self):
'add physical lot works when hidden tree fields are not loaded'
LotQt = Pool().get('lot.qt')
saved_lots = []
class FakeLot:
def __init__(self):
self.sale_line = None
self.id = 1
def set_current_quantity(self, quantity, gross_quantity, type_):
self.quantity = quantity
self.gross_quantity = gross_quantity
self.quantity_type = type_
def get_current_quantity_converted(self):
return self.quantity
@classmethod
def save(cls, lots):
saved_lots.extend(lots)
class AddLine:
lot_qt = Decimal('10')
lot_unit = Mock()
lot_unit_line = Mock()
lot_quantity = Decimal('10')
lot_gross_quantity = Decimal('11')
line = Mock(product=Mock(), fees=[])
line.purchase = Mock()
lqt = LotQt()
lqt.lot_p = Mock(line=line)
lqt.lot_s = None
lqt.lot_shipment_in = None
lqt.lot_shipment_internal = None
lqt.lot_shipment_out = None
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': FakeLot,
'fee.lots': Mock(),
'purchase.purchase': Mock(),
}[name]
lqt.add_physical_lot(AddLine())
self.assertEqual(len(saved_lots), 1)
self.assertIsNone(saved_lots[0].lot_chunk_key)
self.assertEqual(saved_lots[0].lot_premium, Decimal(0))
def test_get_strategy_mtm_price_applies_component_ratio(self):
'strategy mtm price applies component ratios'
price_index = Mock(get_price=Mock(return_value=Decimal('100')))
strategy = Mock(
scenario=Mock(
valuation_date='2026-03-29',
use_last_price=True,
),
currency=Mock(),
)
strategy.components = [Mock(
price_source_type='curve',
price_index=price_index,
price_matrix=None,
ratio=Decimal('25'),
)]
line = Mock(unit=Mock())
self.assertEqual(
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('25.0000'))
price_index.get_price.assert_called_once_with(
'2026-03-29',
line.unit,
strategy.currency,
relative_last=True)
def test_get_strategy_mtm_price_keeps_signed_component_spread(self):
'strategy mtm price keeps signed spread between different components'
strategy = Mock(
scenario=Mock(
valuation_date='2026-03-29',
use_last_price=True,
),
currency=Mock(),
)
strategy.components = [
Mock(
price_source_type='curve',
price_index=Mock(get_price=Mock(return_value=Decimal('120'))),
price_matrix=None,
ratio=Decimal('100'),
),
Mock(
price_source_type='curve',
price_index=Mock(get_price=Mock(return_value=Decimal('80'))),
price_matrix=None,
ratio=Decimal('-100'),
),
]
line = Mock(unit=Mock())
self.assertEqual(
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('40.0000'))
def test_get_strategy_mtm_price_uses_fixed_pricing_component(self):
'strategy mtm price uses fixed components like constant price curves'
strategy = Mock(
scenario=Mock(
valuation_date='2026-03-29',
use_last_price=True,
),
currency=Mock(),
)
strategy.components = [Mock(
price_source_type='fixed',
price_index=None,
price_matrix=None,
get_price=Mock(return_value=Decimal('75')),
ratio=Decimal('50'),
)]
line = Mock(unit=Mock())
self.assertEqual(
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('37.5000'))
def test_sale_line_is_unmatched_checks_lot_links(self):
'sale line unmatched helper ignores empty matches and detects linked purchases'
sale_line = Mock()
sale_line.get_matched_lines.return_value = []
self.assertTrue(
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
linked = Mock(lot_p=Mock(line=Mock()))
sale_line.get_matched_lines.return_value = [linked]
self.assertFalse(
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
def test_regenerate_for_purchase_lines_deduplicates_targets(self):
'targeted valuation regeneration processes each purchase line once'
Valuation = Pool().get('valuation.valuation')
line = Mock(id=10)
with patch.object(Valuation, 'generate') as generate:
Valuation.regenerate_for_purchase_lines([line, line])
generate.assert_called_once_with(line, valuation_type='all')
def test_regenerate_for_sale_lines_deduplicates_targets(self):
'targeted valuation regeneration processes each sale line once'
Valuation = Pool().get('valuation.valuation')
sale_line = Mock(id=20)
with patch.object(
Valuation, 'generate_from_sale_line') as generate_from_sale_line:
Valuation.regenerate_for_sale_lines([sale_line, sale_line])
generate_from_sale_line.assert_called_once_with(
sale_line, valuation_type='all')
def test_parse_numbers_supports_inline_and_legacy_separators(self):
'parse_numbers keeps supporting inline entry and legacy separators'
self.assertEqual(
valuation_module.ValuationProcess._parse_numbers(
'PUR-001 PUR-002, PUR-003\nPUR-004;PUR-005'
),
['PUR-001', 'PUR-002', 'PUR-003', 'PUR-004', 'PUR-005'])
def test_get_generate_types_maps_business_groups(self):
'valuation type groups map to the expected stored valuation types'
Valuation = Pool().get('valuation.valuation')
self.assertEqual(
Valuation._get_generate_types('fees'),
{'line fee', 'pur. fee', 'sale fee', 'shipment fee'})
self.assertEqual(
Valuation._get_generate_types('derivatives'),
{'derivative'})
self.assertIn('pur. priced', Valuation._get_generate_types('goods'))
self.assertIn('mtm', Valuation._get_generate_types('goods'))
self.assertIn('pur. mtm', Valuation._get_generate_types('goods'))
self.assertIn('sale mtm', Valuation._get_generate_types('goods'))
def test_filter_values_by_types_keeps_matching_entries_only(self):
'type filtering keeps only the requested valuation entries'
Valuation = Pool().get('valuation.valuation')
values = [
{'type': 'pur. fee', 'amount': Decimal('10')},
{'type': 'pur. priced', 'amount': Decimal('20')},
{'type': 'derivative', 'amount': Decimal('30')},
]
self.assertEqual(
Valuation._filter_values_by_types(
values, {'pur. fee', 'derivative'}),
[
{'type': 'pur. fee', 'amount': Decimal('10')},
{'type': 'derivative', 'amount': Decimal('30')},
])
def test_delete_existing_purchase_clears_matched_sale_line_only_values(self):
'purchase valuation cleanup removes matched sale-only valuation rows'
Valuation = Pool().get('valuation.valuation')
today = datetime.date(2026, 7, 1)
sale_line = Mock(id=30)
sale_lot = Mock(sale_line=sale_line)
purchase_lot = Mock(id=20, lot_type='virtual', sale_line=None)
line = Mock(
id=10,
finished=False,
lots=[purchase_lot],
get_matched_lines=Mock(return_value=[]))
valuation_model = Mock()
valuation_line_model = Mock()
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: {
'ir.date': Mock(today=Mock(return_value=today)),
'valuation.valuation': valuation_model,
'valuation.valuation.line': valuation_line_model,
'lot.qt': lot_qt_model,
}[name]
Valuation._delete_existing(line)
valuation_model.search.assert_any_call([
('line', '=', line.id),
('date', '=', today),
])
valuation_model.search.assert_any_call([
('sale_line', '=', sale_line.id),
('line', '=', None),
('date', '=', today),
])
valuation_line_model.search.assert_any_call([
('sale_line', '=', sale_line.id),
('line', '=', None),
])
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_purchase_derivative_pnl_uses_direction_and_market_delta(self):
'purchase derivative pnl is entry versus market with long short direction'
Valuation = Pool().get('valuation.valuation')
today = datetime.date(2026, 7, 16)
currency = Mock(id=1)
company_currency = Mock(id=2)
unit = Mock(id=3)
price_index = Mock(
price_index='ICE',
get_price_per_qt=Mock(return_value=Decimal('100')),
get_price=Mock(return_value=Decimal('80')))
party = Mock(id=4)
product = Mock(id=5)
purchase = Mock(
id=6,
currency=currency,
company=Mock(currency=company_currency))
line = Mock(id=7, unit=unit, purchase=purchase)
def derivative(direction):
return Mock(
price_index=price_index,
price=Decimal('100'),
direction=direction,
quantity=Decimal('10'),
party=party,
product=product)
line.derivatives = [derivative('long'), derivative('short')]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock, patch.object(
Valuation, '_base_amount_values',
side_effect=[
(Decimal('-200'), Decimal('1'), 2),
(Decimal('200'), Decimal('1'), 2),
]):
PoolMock.return_value.get.return_value = Mock(
today=Mock(return_value=today))
values = Valuation.create_pnl_der_from_line(line)
self.assertEqual(values[0]['amount'], Decimal('-200.00'))
self.assertEqual(values[0]['base_amount'], Decimal('-200'))
self.assertEqual(values[0]['date'], today)
self.assertIsNone(values[0]['mtm_price'])
self.assertIsNone(values[0]['mtm'])
self.assertEqual(values[1]['amount'], Decimal('200.00'))
self.assertEqual(values[1]['base_amount'], Decimal('200'))
self.assertEqual(values[1]['date'], today)
self.assertIsNone(values[1]['mtm_price'])
self.assertIsNone(values[1]['mtm'])
def test_sale_derivative_pnl_uses_direction_and_market_delta(self):
'sale derivative pnl is stored in amount and base amount only'
Valuation = Pool().get('valuation.valuation')
today = datetime.date(2026, 7, 16)
currency = Mock(id=1)
company_currency = Mock(id=2)
unit = Mock(id=3)
price_index = Mock(
price_index='ICE',
get_price_per_qt=Mock(return_value=Decimal('100')),
get_price=Mock(return_value=Decimal('120')))
derivative = Mock(
price_index=price_index,
price=Decimal('100'),
direction='long',
quantity=Decimal('10'),
party=Mock(id=4),
product=Mock(id=5))
sale = Mock(
id=6,
currency=currency,
company=Mock(currency=company_currency))
sale_line = Mock(
id=7,
unit=unit,
sale=sale,
derivatives=[derivative])
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock, patch.object(
Valuation, '_base_amount_values',
return_value=(Decimal('200'), Decimal('1'), 2)):
PoolMock.return_value.get.return_value = Mock(
today=Mock(return_value=today))
values = Valuation.create_pnl_der_from_sale_line(sale_line)
self.assertEqual(values[0]['price'], Decimal('100.0000'))
self.assertEqual(values[0]['date'], today)
self.assertEqual(values[0]['amount'], Decimal('200.00'))
self.assertEqual(values[0]['base_amount'], Decimal('200'))
self.assertIsNone(values[0]['mtm_price'])
self.assertIsNone(values[0]['mtm'])
def test_update_daily_snapshot_generates_all_purchases_and_unmatched_sales(self):
'daily valuation cron snapshots purchases and unmatched sale lines'
Valuation = Pool().get('valuation.valuation')
PurchaseLine = Mock()
SaleLine = Mock()
purchase_lines = [Mock(id=1), Mock(id=2)]
unmatched_sale_line = Mock(id=3)
unmatched_sale_line.get_matched_lines.return_value = []
matched_sale_line = Mock(id=4)
matched_sale_line.get_matched_lines.return_value = [
Mock(lot_p=Mock(line=Mock(id=5)))]
PurchaseLine.search.return_value = purchase_lines
SaleLine.search.return_value = [unmatched_sale_line, matched_sale_line]
with patch(
'trytond.modules.purchase_trade.valuation.Pool'
) as PoolMock, patch.object(
Valuation, 'generate') as generate, patch.object(
Valuation, 'generate_from_sale_line'
) as generate_from_sale_line:
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.line': PurchaseLine,
'sale.line': SaleLine,
}[name]
Valuation.update_daily_snapshot()
generate.assert_any_call(purchase_lines[0])
generate.assert_any_call(purchase_lines[1])
self.assertEqual(generate.call_count, 2)
generate_from_sale_line.assert_called_once_with(unmatched_sale_line)
def test_update_daily_pricing_checks_and_recomputes_all_lines(self):
'daily pricing cron refreshes pricing and line prices'
Pricing = Pool().get('pricing.pricing')
PurchaseLine = Mock()
SaleLine = Mock()
purchase_lines = [Mock(id=1), Mock(id=2)]
sale_lines = [Mock(id=3), Mock(id=4)]
PurchaseLine.search.return_value = purchase_lines
SaleLine.search.return_value = sale_lines
with patch(
'trytond.modules.purchase_trade.pricing.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.line': PurchaseLine,
'sale.line': SaleLine,
}[name]
Pricing.update_daily_pricing()
PurchaseLine.search.assert_called_once_with([])
SaleLine.search.assert_called_once_with([])
for line in purchase_lines + sale_lines:
line.check_pricing.assert_called_once_with()
line._recompute_trade_price_fields.assert_called_once_with()
PurchaseLine.save.assert_called_once_with(purchase_lines)
SaleLine.save.assert_called_once_with(sale_lines)
def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self):
'purchase fee valuation treats an uncomputed rate amount as zero'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Financing fees')
supplier = Mock(id=4)
lot = Mock(id=5, sale_line=None, lot_type='virtual')
lot.get_current_quantity_converted.return_value = Decimal('10')
fee = Mock(
id=11,
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='rate',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
fee.get_amount.return_value = None
line = Mock(
id=6,
lots=[lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=7, currency=currency),
unit=unit,
)
fee.line = line
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values[0]['amount'], Decimal('0'))
self.assertEqual(values[0]['fee'], fee.id)
def test_create_pnl_fee_from_line_ignores_orphan_fee_lot(self):
'purchase fee valuation ignores fees without business attachment'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
lot = Mock(id=5, sale_line=None, lot_type='virtual')
lot.get_current_quantity_converted.return_value = Decimal('10')
fee = Mock(
line=None,
sale_line=None,
shipment_in=None,
shipment_out=None,
shipment_internal=None,
)
line = Mock(
id=6,
lots=[lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=7, currency=currency),
unit=unit,
)
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values, [])
def test_fee_quantity_sync_uses_physical_lots_when_present(self):
'fee quantity sync ignores virtual lot once physical lots exist'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 1
fee.mode = 'perqt'
fee.quantity = Decimal('100')
fee.unit = Mock()
virtual = Mock(lot_type='virtual')
virtual.get_current_quantity_converted.return_value = Decimal('100')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('40')
fee_lots = Mock()
fee_lots.search.return_value = [
Mock(lot=virtual),
Mock(lot=physical),
]
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(Fee, 'save') as save:
PoolMock.return_value.get.return_value = fee_lots
fee.sync_quantity_from_lots()
self.assertEqual(fee.quantity, Decimal('40.00000'))
save.assert_called_once_with([fee])
def test_shipment_fee_lot_domain_keeps_open_virtual_lots(self):
'shipment fee lot domain keeps virtual lots after partial physical add'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.line = None
fee.sale_line = None
fee.shipment_in = Mock(id=7)
fee.shipment_internal = None
fee.shipment_out = None
physical = Mock(id=2599, lot_type='physic')
virtual_a = Mock(id=2666, lot_type='virtual')
virtual_b = Mock(id=2623, lot_type='virtual')
physical.getVlot_p.return_value = virtual_a
Lot = Mock()
Lot.search.return_value = [physical]
LotQt = Mock()
LotQt.search.return_value = [
Mock(lot_p=virtual_a),
Mock(lot_p=virtual_b),
]
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': Lot,
'lot.qt': LotQt,
}[name]
lots = fee.get_lots(None)
self.assertEqual(lots, [physical, virtual_a, virtual_b])
def test_fee_ppack_auto_sync_keeps_auto_quantity(self):
'per packing auto fee keeps the on-change quantity on save'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 1
fee.mode = 'ppack'
fee.auto_calculation = True
fee.quantity = Decimal('3')
fee.unit = Mock()
lot = Mock(lot_type='physic', lot_qt=Decimal('990'))
fee_lots = Mock()
fee_lots.search.return_value = [Mock(lot=lot)]
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(Fee, 'save') as save:
PoolMock.return_value.get.return_value = fee_lots
fee.sync_quantity_from_lots()
self.assertEqual(fee.quantity, Decimal('3'))
save.assert_not_called()
def test_fee_linked_price_updates_fee_price(self):
'fee linked price converts to the accounting currency price'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.enable_linked_currency = True
fee.linked_price = Decimal('15')
fee.linked_currency = Mock(factor=Decimal('0.01'))
fee.linked_unit = Mock()
fee.unit = Mock()
Uom = Mock()
Uom.compute_qty.return_value = Decimal('2')
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = Uom
fee.on_change_linked_price()
self.assertEqual(fee.price, Decimal('0.3000'))
def test_fee_pnl_regeneration_uses_shipment_lotqt_purchase_line(self):
'fee pnl regeneration follows shipment lot.qt back to purchase line'
Fee = Pool().get('fee.fee')
purchase_line = Mock(id=1)
lot_p = Mock(line=purchase_line, sale_line=None)
shipment = Mock(
incoming_moves=[],
lotqt=[Mock(lot_p=lot_p, lot_s=None)],
)
fee = Mock(line=None, sale_line=None, lots=[], shipment_in=shipment)
valuation = Mock()
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = valuation
Fee._regenerate_fee_pnl(fees=[fee])
valuation.generate.assert_called_once_with(
purchase_line, valuation_type='fees')
valuation.generate_from_sale_line.assert_not_called()
def test_fee_pnl_regeneration_updates_purchase_and_sale_lot_lines(self):
'fee pnl regeneration follows physical fee lots to purchase and sale'
Fee = Pool().get('fee.fee')
purchase_line = Mock(id=1)
sale_line = Mock(id=2)
lot = Mock(line=purchase_line, sale_line=sale_line)
fee = Mock(line=None, sale_line=None, lots=[lot], shipment_in=None)
valuation = Mock()
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = valuation
Fee._regenerate_fee_pnl(fees=[fee])
valuation.generate.assert_called_once_with(
purchase_line, valuation_type='fees')
valuation.generate_from_sale_line.assert_called_once_with(
sale_line, valuation_type='fees')
def test_purchase_fee_pnl_ignores_virtual_fee_lot_when_physical_exists(self):
'purchase fee pnl uses physical fee lots instead of the residual virtual'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Handling')
supplier = Mock(id=4)
virtual = Mock(id=5, sale_line=None, lot_type='virtual')
virtual.get_current_quantity_converted.return_value = Decimal('60')
physical = Mock(id=6, sale_line=None, lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('40')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='perqt',
price=Decimal('2'),
currency=currency,
shipment_in=None,
sale_line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
fee.is_effective_fee_lot.side_effect = lambda lot: lot is physical
fee.get_price_per_qt.return_value = Decimal('2')
line = Mock(
id=7,
lots=[virtual, physical],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=8, currency=currency),
unit=unit,
)
fee.line = line
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(len(values), 1)
self.assertEqual(values[0]['lot'], physical.id)
self.assertEqual(values[0]['quantity'], Decimal('40.00000'))
self.assertEqual(values[0]['amount'], Decimal('-80.00'))
def test_purchase_rate_fee_amount_uses_virtual_lot_quantity(self):
'purchase rate fee amount uses the financing delta as absolute period'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'rate'
fee.price = Decimal('12')
fee.fee_date = None
fee.quantity = None
fee.unit = Mock()
fee.shipment_in = None
lot = Mock()
lot.get_current_quantity_converted.return_value = Decimal('10')
fee.line = Mock(
unit_price=Decimal('100'),
lots=[lot],
estimated_date=[
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23),
fin_int_delta=10,
),
],
)
fee.sale_line = None
self.assertEqual(fee.get_amount(), Decimal('3.33'))
def test_sale_rate_fee_amount_uses_absolute_financing_delta(self):
'sale rate fee amount uses the financing delta as absolute period'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'rate'
fee.price = Decimal('12')
fee.fee_date = None
fee.quantity = Decimal('10')
fee.unit = Mock()
fee.shipment_in = None
fee.line = None
fee.sale_line = Mock(
unit_price=Decimal('100'),
lots=[],
estimated_date=[
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23),
fin_int_delta=10,
),
],
)
self.assertEqual(fee.get_amount(), Decimal('3.33'))
def test_purchase_line_bldate_uses_shipment_bl_before_estimated_date(self):
'purchase line BL trigger uses the real shipment BL date first'
Line = Pool().get('purchase.line')
line = Line()
line.lots = [
Mock(
lot_shipment_in=Mock(
bl_date=datetime.date(2026, 6, 3))),
]
line.estimated_date = [
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23)),
]
self.assertEqual(
line.get_date('bldate'),
datetime.date(2026, 6, 3))
def test_sale_line_bldate_uses_shipment_bl_before_estimated_date(self):
'sale line BL trigger uses the real shipment BL date first'
Line = Pool().get('sale.line')
line = Line()
line.lots = [
Mock(
lot_shipment_in=Mock(
bl_date=datetime.date(2026, 6, 3))),
]
line.estimated_date = [
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23)),
]
self.assertEqual(
line.get_date('bldate'),
datetime.date(2026, 6, 3))
def test_sale_rate_fee_amount_stays_positive_with_negative_delta(self):
'sale rate fee display amount is unsigned even when financing delta is negative'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.mode = 'rate'
fee.price = Decimal('12')
fee.fee_date = None
fee.quantity = Decimal('10')
fee.unit = Mock()
fee.shipment_in = None
fee.line = None
fee.sale_line = Mock(
unit_price=Decimal('100'),
lots=[],
estimated_date=[
Mock(
trigger='bldate',
estimated_date=datetime.date(2026, 5, 23),
fin_int_delta=-10,
),
],
)
self.assertEqual(fee.get_amount(), Decimal('3.33'))
def test_fee_pnl_applies_pay_sign_to_unsigned_rate_amount(self):
'fee pnl sign is applied once from PAY/REC'
Valuation = Pool().get('valuation.valuation')
lot = Mock()
lot.get_current_quantity_converted.return_value = Decimal('10')
fee = Mock(
mode='rate',
price=Decimal('12'),
unit=Mock(),
line=None,
sale_line=Mock(
unit_price=Decimal('100'),
estimated_date=[
Mock(trigger='bldate', fin_int_delta=-10),
],
),
)
self.assertEqual(
Valuation._fee_amount_for_lot_or_zero(fee, lot),
Decimal('3.33'))
def test_create_pnl_price_from_line_keeps_finished_physical_sale_line(self):
'purchase valuation keeps finished sale-side pnl on physical lots'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
company = Mock(currency=currency.id)
unit = Mock(id=1)
product = Mock(id=1)
party = Mock(id=1)
sale = Mock(id=2, currency=currency, company=company, party=party)
finished_sale_line = Mock(
finished=True,
price_type='priced',
mtm=[],
sale=sale,
unit=unit,
product=product,
)
sale_lot = Mock(
id=3,
sale_line=finished_sale_line,
lot_price_sale=Decimal('20'),
lot_type='physic',
)
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
purchase_lot = Mock(
id=4,
sale_line=None,
lot_price=Decimal('10'),
lot_type='physic',
)
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
line = Mock(
finished=False,
lots=[purchase_lot],
price_type='priced',
mtm=[],
purchase=Mock(id=1, currency=currency, company=company, party=party),
unit=unit,
product=product,
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
with patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.qt': lot_qt_model,
'currency.currency': Mock(),
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
}[name]
values = Valuation.create_pnl_price_from_line(line)
self.assertEqual(len(values), 2)
self.assertEqual(values[0]['type'], 'pur. priced')
self.assertEqual(values[0]['sale_line'], finished_sale_line.id)
self.assertEqual(values[1]['type'], 'sale priced')
self.assertEqual(values[1]['sale_line'], finished_sale_line.id)
self.assertEqual(values[1]['reference'], 'Sale/Physic')
def test_purchase_open_pnl_price_links_sale_from_lot_qt(self):
'purchase-side open price pnl is linked to the matched sale via lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
company = Mock(currency=currency.id)
unit = Mock(id=1)
product = Mock(id=1)
purchase_party = Mock(id=1)
sale_party = Mock(id=2)
sale = Mock(id=3, currency=currency, company=company, party=sale_party)
sale_line = Mock(
id=4,
finished=False,
price_type='priced',
mtm=[],
sale=sale,
unit=unit,
product=product,
)
sale_lot = Mock(
id=5,
sale_line=sale_line,
lot_price_sale=Decimal('20'),
lot_type='virtual',
)
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
purchase_lot = Mock(
id=6,
sale_line=None,
lot_price=Decimal('10'),
lot_type='virtual',
)
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
line = Mock(
finished=False,
lots=[purchase_lot],
price_type='priced',
mtm=[],
purchase=Mock(
id=7, currency=currency, company=company, party=purchase_party),
unit=unit,
product=product,
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('2'),
lot_unit=unit,
lot_shipment_in=None)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.qt': lot_qt_model,
'currency.currency': Mock(),
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
}[name]
values = Valuation.create_pnl_price_from_line(line)
purchase_values = [v for v in values if v['type'] == 'pur. priced']
self.assertEqual(purchase_values[0]['sale'], sale.id)
self.assertEqual(purchase_values[0]['sale_line'], sale_line.id)
def test_purchase_open_pnl_fee_links_sale_from_lot_qt(self):
'purchase-side open fee pnl is linked to the matched sale via lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Financing interests')
supplier = Mock(id=4)
sale = Mock(id=5)
sale_line = Mock(id=6, sale=sale, finished=False)
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='lumpsum',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
line = Mock(
id=9,
finished=False,
lots=[purchase_lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=10, currency=currency),
unit=unit,
)
fee.line = line
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('2'),
lot_unit=unit,
lot_shipment_in=None)]
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': lot_qt_model,
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values[0]['type'], 'pur. fee')
self.assertEqual(values[0]['sale'], sale.id)
self.assertEqual(values[0]['sale_line'], sale_line.id)
def test_purchase_pnl_fee_deduplicates_sale_lot_seen_twice(self):
'purchase-side fee pnl does not duplicate a sale lot already in matching'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Broker commission')
supplier = Mock(id=4)
sale = Mock(id=5)
sale_line = Mock(id=6, sale=sale, finished=False)
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
fee = Mock(
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='lumpsum',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=sale_line,
unit=unit,
)
line = Mock(
id=9,
finished=False,
lots=[sale_lot],
get_matched_lines=Mock(return_value=[Mock(lot_s=sale_lot)]),
purchase=Mock(id=10, currency=currency),
unit=unit,
)
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 5, 14))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(len(values), 1)
self.assertEqual(values[0]['type'], 'sale fee')
self.assertEqual(values[0]['lot'], sale_lot.id)
def test_purchase_open_fee_pnl_splits_by_matched_sale_lotqt(self):
'purchase open fee pnl keeps one segment per matched sale lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
product = Mock(id=3, name='Broker commission')
supplier = Mock(id=4)
purchase = Mock(id=5, currency=currency, company=Mock(currency=currency))
line = Mock(id=6, purchase=purchase, unit=unit, finished=False)
purchase_lot = Mock(id=7, sale_line=None, lot_type='virtual')
purchase_lot.get_current_quantity_converted.return_value = Decimal('60')
line.lots = [purchase_lot]
line.get_matched_lines.return_value = []
sales = [Mock(id=20 + index) for index in range(3)]
sale_lines = [
Mock(id=30 + index, sale=sales[index], finished=False)
for index in range(3)]
sale_lots = [
Mock(id=40 + index, sale_line=sale_lines[index])
for index in range(3)]
lot_qts = [
Mock(
id=50,
lot_s=sale_lots[0],
lot_quantity=Decimal('10'),
lot_unit=unit,
lot_shipment_in=None),
Mock(
id=51,
lot_s=sale_lots[1],
lot_quantity=Decimal('20'),
lot_unit=unit,
lot_shipment_in=None),
Mock(
id=52,
lot_s=sale_lots[2],
lot_quantity=Decimal('30'),
lot_unit=unit,
lot_shipment_in=None),
]
fee = Mock(
id=60,
line=line,
sale_line=None,
shipment_in=None,
shipment_out=None,
shipment_internal=None,
product=product,
supplier=supplier,
type='budgeted',
p_r='pay',
mode='perqt',
price=Decimal('2'),
currency=currency,
unit=unit,
)
fee.get_price_per_qt.return_value = Decimal('2')
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
lot_qt_model = Mock()
lot_qt_model.search.return_value = lot_qts
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, 6, 30))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': lot_qt_model,
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(len(values), 3)
self.assertEqual(
[value['sale_line'] for value in values],
[sale_line.id for sale_line in sale_lines])
self.assertEqual(
[value['quantity'] for value in values],
[Decimal('10.00000'), Decimal('20.00000'), Decimal('30.00000')])
self.assertEqual(
[value['amount'] for value in values],
[Decimal('-20.00'), Decimal('-40.00'), Decimal('-60.00')])
self.assertEqual(len(Valuation._dedupe_values(values)), 3)
def test_sale_open_fee_pnl_splits_by_shipment_lotqt(self):
'sale open fee pnl is split between shipped and unshipped lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
shipment = Mock(id=70)
sale = Mock(id=3, currency=currency)
sale_line = Mock(id=4, sale=sale, unit=unit, finished=False)
sale_lot = Mock(id=5, sale_line=sale_line, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('220')
sale_line.lots = [sale_lot]
fee = Mock(
id=12,
product=Mock(id=6, name='Broker commission'),
supplier=Mock(id=7),
type='budgeted',
p_r='pay',
mode='perqt',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=sale_line,
line=None,
shipment_out=None,
shipment_internal=None,
unit=unit,
)
fee.is_effective_fee_lot.return_value = True
fee.get_price_per_qt.return_value = Decimal('10')
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('88'),
lot_unit=unit,
lot_shipment_in=shipment),
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('132'),
lot_unit=unit,
lot_shipment_in=None),
]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 5, 14))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': lot_qt_model,
}[name]
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
self.assertEqual(len(values), 2)
self.assertEqual(values[0]['fee'], fee.id)
self.assertEqual(values[1]['fee'], fee.id)
self.assertEqual(values[0]['shipment_in'], shipment.id)
self.assertEqual(values[0]['quantity'], Decimal('88.00000'))
self.assertEqual(values[0]['amount'], Decimal('-880.00'))
self.assertIsNone(values[1]['shipment_in'])
self.assertEqual(values[1]['quantity'], Decimal('132.00000'))
self.assertEqual(values[1]['amount'], Decimal('-1320.00'))
def test_shipment_fee_pnl_sums_all_shipment_lotqt(self):
'shipment fee pnl sums every lot.qt linked to the shipment'
Valuation = Pool().get('valuation.valuation')
shipment = Mock(id=70)
unit = Mock(id=2)
lot = Mock(id=5, lot_type='virtual', sale_line=None)
lot.get_current_quantity_converted.return_value = Decimal('18000')
line = Mock(id=4, unit=unit)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(id=1, lot_quantity=Decimal('5000'), lot_unit=unit,
lot_shipment_in=shipment),
Mock(id=2, lot_quantity=Decimal('6000'), lot_unit=unit,
lot_shipment_in=shipment),
Mock(id=3, lot_quantity=Decimal('7000'), lot_unit=unit,
lot_shipment_in=shipment),
]
fee = Mock(shipment_in=shipment)
segments = Valuation._fee_segments_for_fee(
fee, lot, line, lot_qt_model)
self.assertEqual(segments, [{
'quantity': Decimal('18000'),
'share': Decimal('1'),
'sale_lot': None,
'sale_line': None,
'shipment_in': shipment.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_snapshot_identity_keeps_mtm_curves_distinct(self):
'valuation snapshot identity keeps strategy curves distinct'
Valuation = Pool().get('valuation.valuation')
base = {
'purchase': 1,
'line': 2,
'sale': None,
'sale_line': None,
'lot': 5,
'type': 'pur. priced',
'reference': 'Open',
'counterparty': 6,
'product': 7,
'state': 'fixed',
'strategy': 8,
'shipment_in': None,
'mtm_curve': 10,
}
other_curve = base.copy()
other_curve['mtm_curve'] = 20
self.assertNotEqual(
Valuation._value_key(base),
Valuation._value_key(other_curve))
self.assertIn(
('mtm_curve', '=', 10),
Valuation._snapshot_identity_domain(base))
def test_generate_from_matched_sale_line_does_not_create_snapshot(self):
'matched sale line pnl is owned by the matched purchase line'
Valuation = Pool().get('valuation.valuation')
purchase_line = Mock(id=10)
sale_line = Mock()
sale_line.get_matched_lines.return_value = [
Mock(lot_p=Mock(line=purchase_line))]
with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object(
Valuation, 'create_pnl_fee_from_sale_line') as create_fees, patch.object(
Valuation, 'generate') as generate:
Valuation.generate_from_sale_line(sale_line, valuation_type='fees')
generate.assert_called_once_with(purchase_line, valuation_type='fees')
delete_existing.assert_not_called()
create_fees.assert_not_called()
def test_purchase_open_pnl_fee_skips_zero_virtual_lot(self):
'purchase-side fee pnl ignores open virtual lots with no quantity'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
purchase_lot.get_current_quantity_converted.return_value = Decimal('0')
line = Mock(
id=9,
finished=False,
lots=[purchase_lot],
get_matched_lines=Mock(return_value=[]),
purchase=Mock(id=10, currency=currency),
unit=unit,
)
fee_lots = Mock()
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
self.assertEqual(values, [])
fee_lots.search.assert_not_called()
def test_sale_open_pnl_fee_skips_zero_virtual_lot(self):
'sale-side fee pnl ignores open virtual lots with no quantity'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
sale_lot = Mock(id=7, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('0')
sale_line = Mock(
id=6,
finished=False,
lots=[sale_lot],
sale=Mock(id=5, currency=currency),
unit=unit,
)
fee_lots = Mock()
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
self.assertEqual(values, [])
fee_lots.search.assert_not_called()
def test_sale_report_crop_name_handles_missing_crop(self):
'sale report crop name returns an empty string when crop is missing'
Sale = Pool().get('sale.sale')
sale = Sale()
sale.crop = None
self.assertEqual(sale.report_crop_name, '')
sale.crop = Mock(name='crop')
sale.crop.name = 'Main Crop'
self.assertEqual(sale.report_crop_name, 'Main Crop')
def test_sale_line_default_pricing_rule_comes_from_configuration(self):
'sale line pricing_rule defaults to the purchase_trade singleton value'
SaleLine = Pool().get('sale.line')
config = Mock(pricing_rule='Default pricing rule')
configuration_model = Mock()
configuration_model.search.return_value = [config]
with patch(
'trytond.modules.purchase_trade.sale.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = configuration_model
self.assertEqual(
SaleLine.default_pricing_rule(), 'Default pricing rule')
def test_sale_line_compute_unit_price_keeps_existing_value_when_base_returns_none(self):
'sale line quantity changes keep the manual unit price when no sale price is found'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.unit_price = Decimal('123.45')
with patch('trytond.modules.purchase_trade.sale.super') as super_mock:
super_mock.return_value.compute_unit_price.return_value = None
self.assertEqual(
line.compute_unit_price(),
Decimal('123.45'))
def test_sale_line_linked_unit_factor_keeps_currency_factor_without_product(self):
'linked price conversion does not crash when automation builds a sale line before setting product'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.enable_linked_currency = True
line.linked_currency = Mock(factor=Decimal('2'))
line.linked_unit = Mock()
line.unit = None
self.assertEqual(line._get_linked_unit_factor(), Decimal('2'))
def test_get_sla_cost_returns_empty_tuple_when_no_place_matches(self):
'controller sla helper keeps a stable tuple contract when no sla rule matches'
Party = Pool().get('party.party')
party = Party()
party.sla = []
self.assertEqual(
party.get_sla_cost(Mock()),
(None, None, None, None))
def test_get_sla_cost_matches_country_without_location(self):
'controller sla helper can match a destination country'
Party = Pool().get('party.party')
party = Party()
sla = Mock(id=1)
party.sla = [sla]
country = Mock(id=10)
location = Mock(country=country)
country_place = Mock(
country=country,
location=None,
cost=Decimal('12'),
mode='ppack',
currency=Mock(id=1),
unit=Mock(id=2),
)
place_model = Mock()
place_model.search.return_value = [country_place]
with patch('trytond.modules.purchase_trade.party.Pool') as PoolMock:
PoolMock.return_value.get.return_value = place_model
self.assertEqual(
party.get_sla_cost(location),
(
country_place.cost,
country_place.mode,
country_place.currency,
country_place.unit,
))
def test_get_sla_cost_prioritizes_country_location_pair(self):
'controller sla helper prefers country and location over either alone'
Party = Pool().get('party.party')
party = Party()
sla = Mock(id=1)
party.sla = [sla]
country = Mock(id=10)
location = Mock(country=country)
country_only = Mock(
country=country,
location=None,
cost=Decimal('12'),
mode='ppack',
currency=Mock(id=1),
unit=Mock(id=2),
)
location_only = Mock(
country=None,
location=location,
cost=Decimal('14'),
mode='perqt',
currency=Mock(id=3),
unit=Mock(id=4),
)
pair = Mock(
country=country,
location=location,
cost=Decimal('16'),
mode='rate',
currency=Mock(id=5),
unit=Mock(id=6),
)
place_model = Mock()
place_model.search.return_value = [country_only, location_only, pair]
with patch('trytond.modules.purchase_trade.party.Pool') as PoolMock:
PoolMock.return_value.get.return_value = place_model
self.assertEqual(
party.get_sla_cost(location),
(pair.cost, pair.mode, pair.currency, pair.unit))
def test_get_party_by_name_adds_missing_category_to_existing_party(self):
'existing parties found by automation gain the requested category when missing'
Party = Pool().get('party.party')
existing_party = Mock(id=12)
category = Mock(id=34)
category_model = Mock()
category_model.search.return_value = [category]
party_category_model = Mock()
party_category_model.search.return_value = []
with patch.object(Party, 'search', return_value=[existing_party]), patch(
'trytond.modules.purchase_trade.party.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'party.category': category_model,
'party.party-party.category': party_category_model,
}[name]
party = Party.getPartyByName('SYED SP COT D BD', 'CLIENT')
self.assertIs(party, existing_party)
party_category_model.save.assert_called_once()
def test_create_fee_skips_when_controller_has_no_sla_cost(self):
'shipment fee creation skips cleanly when controller has no matching sla'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.id = 99
shipment.to_location = Mock()
shipment.get_bales = Mock(return_value=Decimal('10'))
controller = Mock()
controller.get_sla_cost.return_value = (None, None, None, None)
fee_model = Mock()
product_model = Mock()
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
'product.product': product_model,
}[name]
shipment.create_fee(controller)
fee_model.save.assert_not_called()
product_model.get_by_name.assert_not_called()
def test_shipment_started_cancel_deletes_draft_stock_account_moves(self):
'started shipment cancel removes draft account moves linked to stock moves'
ShipmentIn = Pool().get('stock.shipment.in')
draft_move = Mock(state='draft')
account_move = Mock()
account_move.search.return_value = [draft_move]
stock_moves = [Mock(id=10), Mock(id=20)]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = account_move
ShipmentIn._delete_draft_account_moves_for_stock_moves(stock_moves)
account_move.search.assert_called_once_with([
('origin', 'in', ['stock.move,10', 'stock.move,20']),
])
account_move.delete.assert_called_once_with([draft_move])
def test_shipment_started_cancel_refuses_posted_stock_account_moves(self):
'started shipment cancel is blocked by posted stock account moves'
ShipmentIn = Pool().get('stock.shipment.in')
posted_move = Mock(state='posted', rec_name='STJ/1')
account_move = Mock()
account_move.search.return_value = [posted_move]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = account_move
with self.assertRaises(UserError):
ShipmentIn._delete_draft_account_moves_for_stock_moves([
Mock(id=10),
])
account_move.delete.assert_not_called()
@with_transaction()
def test_shipment_in_type_detects_dropship(self):
'shipment type is dropship from supplier directly to customer'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.from_location = Mock(type='supplier')
shipment.to_location = Mock(type='customer')
self.assertEqual(shipment.get_shipment_type(), 'dropship')
@with_transaction()
def test_shipment_in_type_defaults_to_inbound(self):
'shipment type is inbound for non supplier-to-customer routes'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.from_location = Mock(type='supplier')
shipment.to_location = Mock(type='storage')
self.assertEqual(shipment.get_shipment_type(), 'inbound')
@with_transaction()
def test_shipment_in_location_domains_follow_trade_direction(self):
'shipment locations exclude invalid trade counterpart directions'
ShipmentIn = Pool().get('stock.shipment.in')
self.assertEqual(
ShipmentIn.from_location.domain,
[('type', '!=', 'customer')])
self.assertEqual(
ShipmentIn.to_location.domain,
[('type', '!=', 'supplier')])
@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')
def test_lot_report_exposes_purchase_creator_and_date(self):
'lots management exposes the purchase creator and purchase date'
LotReport = Pool().get('lot.report')
report = LotReport()
report.r_purchase = Mock(
create_uid=Mock(id=42),
purchase_date=datetime.date(2026, 6, 22),
)
self.assertEqual(report.get_purchase_creator(), 42)
self.assertEqual(
report.get_purchase_date(), datetime.date(2026, 6, 22))
@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_conditionally_required_for_linked_currency(self):
'fee currency does not block linked currency draft saves'
self.assertFalse(fee_module.Fee.currency.required)
self.assertIn('required', fee_module.Fee.currency.states)
self.assertEqual(
fee_module.Fee.currency.states['required'],
~Eval('enable_linked_currency'))
for field in (
fee_module.Fee.linked_price,
fee_module.Fee.linked_currency,
fee_module.Fee.linked_unit):
self.assertFalse(field.required)
self.assertNotIn('required', field.states)
def test_fee_ensure_ordered_purchase_creates_missing_service_purchase(self):
'ordered fee creates missing purchase after linked values are completed'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 10
fee.type = 'ordered'
fee.purchase = None
fee.currency = Mock()
fee.price = Decimal('12')
fee.mode = 'perqt'
fee.product = Mock()
fee.supplier = Mock(addresses=[])
fee.quantity = Decimal('5')
fee.unit = Mock()
fee.shipment_in = None
fee.sale_line = None
fee.line = Mock(
purchase=Mock(
from_location=Mock(),
to_location=Mock(),
payment_term=Mock()))
purchase = Mock()
purchase_model = Mock(return_value=purchase)
line = Mock()
line_model = Mock(return_value=line)
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.fee.Transaction'
) as TransactionMock, patch.object(
Fee, 'save') as save, patch.object(
fee, '_get_effective_fee_lots', return_value=[]), patch.object(
fee, '_create_fee_accruals') as create_accruals:
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.purchase': purchase_model,
'purchase.line': line_model,
}[name]
TransactionMock.return_value.set_context.return_value = (
nullcontext())
self.assertTrue(fee.ensure_ordered_purchase())
self.assertEqual(fee.purchase, purchase)
self.assertEqual(purchase.lines, [line])
self.assertEqual(purchase.currency, fee.currency)
self.assertEqual(line.fee_, fee.id)
self.assertEqual(line.unit_price, Decimal('12.0000'))
purchase_model.save.assert_called_once_with([purchase])
save.assert_called_once_with([fee])
create_accruals.assert_called_once_with()
def test_purchase_service_fee_invoice_reverses_previous_line(self):
're-invoiced service fee creates a reversal and a current line'
Line = base_purchase_module.Line
line = Line()
line.purchase = Mock(id=20)
line.unit_price = Decimal('15')
lot = Mock(id=30)
invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('15'))
fee = Mock(
state='invoiced',
mode='perqt',
get_fee_lots_qt=Mock(return_value=Decimal('12')))
previous_line = Mock(
quantity=Decimal('10'),
unit_price=Decimal('11'),
invoice=Mock(party=Mock()))
reversal_line = Mock()
fee_model = Mock()
fee_model.search.return_value = [fee]
invoice_line_model = Mock()
invoice_line_model.copy.return_value = [reversal_line]
with patch(
'trytond.modules.purchase.purchase.Pool'
) as PoolMock, patch.object(
Line, '_get_last_fee_invoice_line',
return_value=previous_line):
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
'account.invoice.line': invoice_line_model,
}[name]
result = line._get_service_fee_invoice_lines(invoice_line, lot)
self.assertEqual(result, [reversal_line, invoice_line])
self.assertEqual(invoice_line.fee, fee)
self.assertEqual(invoice_line.quantity, Decimal('12'))
fee.get_fee_lots_qt.assert_called_once_with()
invoice_line_model.copy.assert_called_once_with(
[previous_line], default={
'invoice': None,
'quantity': Decimal('-10'),
'unit_price': Decimal('11'),
'party': previous_line.invoice.party,
'origin': str(line),
})
def test_purchase_service_fee_invoice_skips_unchanged_reinvoice(self):
'unchanged re-invoiced service fee does not create a zero note'
Line = base_purchase_module.Line
line = Line()
line.purchase = Mock(id=20)
lot = Mock(id=30)
invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('15'))
fee = Mock(
state='invoiced',
mode='perqt',
get_fee_lots_qt=Mock(return_value=Decimal('12')))
previous_line = Mock(
quantity=Decimal('12'),
unit_price=Decimal('15'),
invoice=Mock(party=Mock()))
fee_model = Mock()
fee_model.search.return_value = [fee]
with patch(
'trytond.modules.purchase.purchase.Pool'
) as PoolMock, patch.object(
Line, '_get_last_fee_invoice_line',
return_value=previous_line):
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
}[name]
self.assertEqual(
line._get_service_fee_invoice_lines(invoice_line, lot), [])
fee.get_fee_lots_qt.assert_called_once_with()
def test_purchase_service_percent_price_fee_invoice_uses_amount_line(self):
'% price service fee invoice uses a single amount line'
Line = base_purchase_module.Line
line = Line()
line.purchase = Mock(id=20)
lot = Mock(id=30)
invoice_line = Mock(quantity=Decimal('12'), unit_price=Decimal('50'))
fee = Mock(
state='not invoiced',
mode='pprice',
warn_percent_price_partial_lots=Mock())
fee_model = Mock()
fee_model.search.return_value = [fee]
with patch(
'trytond.modules.purchase.purchase.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
}[name]
result = line._get_service_fee_invoice_lines(invoice_line, lot)
self.assertEqual(result, [invoice_line])
self.assertEqual(invoice_line.fee, fee)
self.assertEqual(invoice_line.quantity, 1)
self.assertEqual(invoice_line.unit_price, Decimal('50'))
fee.warn_percent_price_partial_lots.assert_called_once_with()
def test_percent_price_fee_amount_uses_full_line_amount(self):
'% price fee ignores quantity when all line lots are linked'
Fee = Pool().get('fee.fee')
fee = Fee()
lot = SimpleNamespace(id=1, lot_type='physic')
fee.mode = 'pprice'
fee.price = Decimal('2')
fee.quantity = Decimal('999')
fee.sale_line = None
fee.shipment_in = None
fee.line = SimpleNamespace(
amount=Decimal('1000'),
unit_price=Decimal('10'),
lots=[lot])
with patch.object(
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_amount(), Decimal('20.00'))
def test_per_quantity_fee_with_padding_uses_lots_plus_padding(self):
'per quantity fee can bill linked lots plus sale invoice padding'
Fee = Pool().get('fee.fee')
fee = Fee()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(unit=unit)
lot = SimpleNamespace(
id=1,
lot_type='physic',
sale_line=sale_line,
sale_invoice_padding=Decimal('2'),
lot_hist=[],
get_current_quantity_converted=(
lambda state=0, unit=None: Decimal('5')))
fee.mode = 'perqt'
fee.price = Decimal('10')
fee.add_padding = True
fee.unit = unit
fee.quantity = Decimal('5')
fee.line = None
fee.sale_line = sale_line
fee.shipment_in = None
with patch.object(
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_billing_quantity(), Decimal('7'))
self.assertEqual(fee.get_amount(), Decimal('70.00'))
def test_percent_price_fee_with_padding_uses_lots_padding_unit_price(self):
'% price fee with padding uses fee lots plus padding as amount base'
Fee = Pool().get('fee.fee')
fee = Fee()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(
unit=unit,
unit_price=Decimal('100'),
amount=Decimal('9999'))
lot = SimpleNamespace(
id=1,
lot_type='physic',
sale_line=sale_line,
sale_invoice_padding=Decimal('2'),
lot_hist=[],
get_current_quantity_converted=(
lambda state=0, unit=None: Decimal('5')))
fee.mode = 'pprice'
fee.price = Decimal('2')
fee.add_padding = True
fee.state = 'not invoiced'
fee.unit = unit
fee.quantity = Decimal('5')
fee.line = None
fee.sale_line = sale_line
fee.shipment_in = None
with patch.object(
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_amount(), Decimal('14.00'))
def test_fee_dn_cn_amount_ignores_padding_for_current_value(self):
'fee DN/CN current value ignores padding while reversal keeps prior line'
Fee = Pool().get('fee.fee')
fee = Fee()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100'))
lot = SimpleNamespace(
id=1,
lot_type='physic',
sale_line=sale_line,
sale_invoice_padding=Decimal('2'),
lot_hist=[],
get_current_quantity_converted=(
lambda state=0, unit=None: Decimal('5')))
fee.mode = 'pprice'
fee.price = Decimal('2')
fee.add_padding = True
fee.unit = unit
fee.quantity = Decimal('5')
fee.line = None
fee.sale_line = sale_line
fee.shipment_in = None
with patch.object(
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_amount(), Decimal('14.00'))
fee.state = 'invoiced'
self.assertEqual(fee.get_amount(), Decimal('10.00'))
with Transaction().set_context(
_purchase_trade_ignore_fee_padding=True):
self.assertEqual(fee.get_amount(), Decimal('10.00'))
def test_percent_price_fee_warns_when_line_lots_are_partial(self):
'% price fee warns when it does not cover all line lots'
Fee = Pool().get('fee.fee')
fee = Fee()
lot_1 = SimpleNamespace(id=1, lot_type='physic')
lot_2 = SimpleNamespace(id=2, lot_type='physic')
fee.mode = 'pprice'
fee.line = SimpleNamespace(lots=[lot_1, lot_2])
fee.sale_line = None
warning = Mock()
warning.format.return_value = 'partial-pprice-fee'
warning.check.return_value = True
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(
fee, '_get_effective_fee_lots', return_value=[lot_1]):
PoolMock.return_value.get.side_effect = lambda name: {
'res.user.warning': warning,
}[name]
with self.assertRaises(UserWarning):
fee.warn_percent_price_partial_lots()
warning.format.assert_called_once_with(
"Partial % price fee lots", [fee])
warning.check.assert_called_once_with('partial-pprice-fee')
def test_percent_price_fee_adjusts_purchase_to_amount_line(self):
'% price fee keeps generated purchase as one amount line'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.type = 'ordered'
fee.mode = 'pprice'
fee.amount = Decimal('20')
fee.product = Mock()
fee.supplier = Mock()
fee.currency = Mock()
purchase_line = Mock(
unit_price=Decimal('10'),
quantity=Decimal('999'),
product=fee.product)
fee.purchase = Mock(
lines=[purchase_line],
party=fee.supplier,
currency=fee.currency)
purchase_model = Mock()
purchase_line_model = Mock()
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.purchase': purchase_model,
'purchase.line': purchase_line_model,
}[name]
fee.adjust_purchase_values()
self.assertEqual(purchase_line.unit_price, Decimal('20'))
self.assertEqual(purchase_line.quantity, 1)
purchase_line_model.save.assert_called_once_with([purchase_line])
purchase_model.save.assert_called_once_with([fee.purchase])
def test_per_quantity_fee_padding_adjusts_generated_purchase_quantity(self):
'per quantity fee with padding updates the generated purchase quantity'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.type = 'ordered'
fee.mode = 'perqt'
fee.add_padding = True
fee.price = Decimal('10')
fee.product = Mock()
fee.supplier = Mock()
fee.currency = Mock()
purchase_line = Mock(
unit_price=Decimal('10'),
quantity=Decimal('5'),
product=fee.product)
fee.purchase = Mock(
lines=[purchase_line],
party=fee.supplier,
currency=fee.currency)
purchase_model = Mock()
purchase_line_model = Mock()
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(
fee, 'get_billing_quantity',
return_value=Decimal('7')):
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.purchase': purchase_model,
'purchase.line': purchase_line_model,
}[name]
fee.adjust_purchase_values()
self.assertEqual(purchase_line.unit_price, Decimal('10'))
self.assertEqual(purchase_line.quantity, Decimal('7'))
purchase_line_model.save.assert_called_once_with([purchase_line])
purchase_model.save.assert_called_once_with([fee.purchase])
def test_purchase_trade_service_fee_invoice_uses_padding_quantity(self):
'service fee invoice line uses billing quantity when padding is enabled'
Line = purchase_module.Line
line = Line()
line.purchase = Mock(id=20)
lot = Mock(id=30)
invoice_line = Mock(quantity=Decimal('5'), unit_price=Decimal('10'))
fee = Mock(
state='not invoiced',
mode='perqt',
add_padding=True,
warn_percent_price_partial_lots=Mock(),
get_billing_quantity=Mock(return_value=Decimal('7')))
fee_model = Mock()
fee_model.search.return_value = [fee]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
}[name]
result = line._get_service_fee_invoice_lines(invoice_line, lot)
self.assertEqual(result, [invoice_line])
self.assertEqual(invoice_line.fee, fee)
self.assertEqual(invoice_line.quantity, Decimal('7'))
fee.get_billing_quantity.assert_called_once_with()
def test_purchase_trade_service_fee_dn_cn_ignores_padding_quantity(self):
'fee DN/CN current invoice line uses final lot quantity without padding'
Line = purchase_module.Line
line = Line()
line.purchase = Mock(id=20)
lot = Mock(id=30)
invoice_line = Mock(quantity=Decimal('7'), unit_price=Decimal('10'))
fee = Mock(
state='invoiced',
mode='perqt',
add_padding=True,
warn_percent_price_partial_lots=Mock(),
get_billing_quantity=Mock(return_value=Decimal('7')),
get_fee_lots_qt=Mock(return_value=Decimal('5')))
fee_model = Mock()
fee_model.search.return_value = [fee]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch.object(
Line, '_get_last_fee_invoice_line',
return_value=Mock(
quantity=Decimal('7'),
unit_price=Decimal('10'),
invoice=Mock(party=Mock()))), patch.object(
Line, '_get_fee_reversal_invoice_line',
return_value=Mock()):
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
}[name]
with Transaction().set_context(
_purchase_trade_ignore_fee_padding=True):
result = line._get_service_fee_invoice_lines(
invoice_line, lot)
self.assertEqual(result, [ANY, invoice_line])
self.assertEqual(invoice_line.quantity, Decimal('5'))
fee.get_billing_quantity.assert_not_called()
fee.get_fee_lots_qt.assert_called_once_with()
def test_lot_invoice_sale_padding_refreshes_per_quantity_fee_amount(self):
'sale padding refreshes displayed per quantity fee amount'
start = lot_module.LotInvoiceStart()
unit = SimpleNamespace(id=1)
lot = SimpleNamespace(id=10, sale_line=SimpleNamespace(unit=unit))
fee = SimpleNamespace(
add_padding=True,
mode='perqt',
price=Decimal('10'),
quantity=Decimal('5'),
_get_effective_fee_lots=Mock(return_value=[lot]),
_get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')),
_convert_padding_quantity=Mock(
side_effect=lambda quantity, unit: Decimal(str(quantity))),
get_quantity=Mock(return_value=Decimal('5')))
fee_line = SimpleNamespace(
fee=fee,
fee_quantity=Decimal('5'),
fee_amount=Decimal('50'))
start.type = 'sale'
start.sale_padding = Decimal('2')
start.lot_s = [SimpleNamespace(lot=lot)]
start.fee_sale = [fee_line]
start.on_change_sale_padding()
self.assertEqual(fee_line.fee_quantity, Decimal('7'))
self.assertEqual(fee_line.fee_amount, Decimal('70.00'))
def test_lot_invoice_sale_padding_refreshes_percent_price_fee_amount(self):
'sale padding refreshes displayed percent price fee amount'
start = lot_module.LotInvoiceStart()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100'))
lot = SimpleNamespace(id=10, sale_line=sale_line)
fee = SimpleNamespace(
add_padding=True,
mode='pprice',
price=Decimal('2'),
quantity=Decimal('5'),
sale_line=sale_line,
_get_effective_fee_lots=Mock(return_value=[lot]),
_get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')),
_convert_padding_quantity=Mock(
side_effect=lambda quantity, unit: Decimal(str(quantity))),
get_quantity=Mock(return_value=Decimal('5')))
fee_line = SimpleNamespace(
fee=fee,
fee_quantity=Decimal('5'),
fee_amount=Decimal('10'))
start.type = 'sale'
start.sale_padding = Decimal('2')
start.lot_s = [SimpleNamespace(lot=lot)]
start.fee_sale = [fee_line]
start.on_change_sale_padding()
self.assertEqual(fee_line.fee_quantity, Decimal('7'))
self.assertEqual(fee_line.fee_amount, Decimal('14.00'))
def test_lot_invoice_final_existing_fee_display_ignores_padding(self):
'final DN/CN fee display ignores padding for the current value'
start = lot_module.LotInvoiceStart()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100'))
lot = SimpleNamespace(id=10, sale_line=sale_line)
fee = SimpleNamespace(
add_padding=True,
state='invoiced',
mode='pprice',
price=Decimal('2'),
quantity=Decimal('5'),
sale_line=sale_line,
_get_effective_fee_lots=Mock(return_value=[lot]),
_get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')),
_convert_padding_quantity=Mock(
side_effect=lambda quantity, unit: Decimal(str(quantity))),
get_quantity=Mock(return_value=Decimal('5')))
fee_line = SimpleNamespace(
fee=fee,
fee_quantity=Decimal('5'),
fee_amount=Decimal('10'))
start.type = 'sale'
start.action = 'final'
start.sale_padding = Decimal('2')
start.lot_s = [SimpleNamespace(lot=lot)]
start.fee_sale = [fee_line]
start.on_change_sale_padding()
self.assertEqual(fee_line.fee_quantity, Decimal('5'))
self.assertEqual(fee_line.fee_amount, Decimal('10.00'))
def test_lot_invoice_default_final_fee_display_ignores_padding(self):
'initial final fee display reads amount without padding'
class FeeStub:
state = 'invoiced'
add_padding = True
@property
def amount(self):
if Transaction().context.get(
'_purchase_trade_ignore_fee_padding'):
return Decimal('14970')
return Decimal('14985')
fee = FeeStub()
self.assertEqual(
lot_module.LotInvoice._fee_display_amount(fee, 'final'),
Decimal('14970'))
def test_fee_get_non_cog_returns_zero_without_move_lines(self):
'fee non-cog amount is zero before any accounting move line exists'
fee = fee_module.Fee()
fee.id = 1
fee.product = Mock(account_stock_in_used=Mock(id=2))
lot = Mock(id=3)
move_line = Mock()
move_line.search.return_value = []
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'account.move.line': move_line,
'currency.currency': Mock(),
'ir.date': Mock(),
'account.configuration': Mock(return_value=Mock()),
'product.uom': Mock(),
}[name]
self.assertEqual(fee.get_non_cog(lot), Decimal(0))
def test_fee_get_cog_returns_zero_without_move_lines(self):
'fee cog amount is zero before any stock accounting move line exists'
fee = fee_module.Fee()
fee.id = 1
fee.product = Mock(account_stock_in_used=Mock(id=2))
lot = Mock(id=3)
move_line = Mock()
move_line.search.return_value = []
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'account.move.line': move_line,
'currency.currency': Mock(),
'ir.date': Mock(),
'account.configuration': Mock(return_value=Mock()),
'product.uom': Mock(),
}[name]
self.assertEqual(fee.get_cog(lot), Decimal(0))
def test_valuation_rejects_fee_without_currency_cleanly(self):
'pnl generation reports a missing fee currency before currency conversion'
fee = Mock(currency=None, product=Mock(name='Maritime freight'))
with self.assertRaises(UserError) as cm:
valuation_module.ValuationBase._check_fee_required_fields(fee)
self.assertIn('Currency is required on fee', str(cm.exception))
def test_purchase_line_default_pricing_rule_comes_from_configuration(self):
'purchase line pricing_rule defaults to the purchase_trade singleton value'
PurchaseLine = Pool().get('purchase.line')
config = Mock(pricing_rule='Default pricing rule')
configuration_model = Mock()
configuration_model.search.return_value = [config]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = configuration_model
self.assertEqual(
PurchaseLine.default_pricing_rule(), 'Default pricing rule')
def test_purchase_delete_blocks_matched_physical_contract(self):
'purchase delete stops when a physical lot already links purchase and sale'
Purchase = Pool().get('purchase.purchase')
matched_lot = Mock(lot_type='physic', line=Mock(), sale_line=Mock())
purchase = Mock(lines=[Mock(lots=[matched_lot])])
with self.assertRaises(UserError):
Purchase.delete([purchase])
def test_purchase_delete_delegates_when_no_matched_physical_lot(self):
'purchase delete keeps default flow when no matched physical lot exists'
Purchase = Pool().get('purchase.purchase')
purchase = Mock(lines=[Mock(lots=[Mock(lot_type='virtual')])])
with patch('trytond.modules.purchase_trade.purchase.super') as super_mock:
Purchase.delete([purchase])
super_mock.return_value.delete.assert_called_once_with([purchase])
def test_sale_delete_blocks_matched_physical_contract(self):
'sale delete stops when a physical lot already links sale and purchase'
Sale = Pool().get('sale.sale')
matched_lot = Mock(lot_type='physic', line=Mock(), sale_line=Mock())
sale = Mock(lines=[Mock(lots=[matched_lot])])
with self.assertRaises(UserError):
Sale.delete([sale])
def test_sale_delete_delegates_when_no_matched_physical_lot(self):
'sale delete keeps default flow when no matched physical lot exists'
Sale = Pool().get('sale.sale')
sale = Mock(lines=[Mock(lots=[Mock(lot_type='virtual')])])
with patch('trytond.modules.purchase_trade.sale.super') as super_mock:
Sale.delete([sale])
super_mock.return_value.delete.assert_called_once_with([sale])
def test_component_quota_uses_quantity_fallback_when_theoretical_is_missing(self):
'component quota does not crash when theoretical quantity is still empty'
SaleComponent = Pool().get('pricing.component')
PurchaseComponent = Pool().get('pricing.component')
sale_component = SaleComponent()
sale_component.nbdays = None
sale_component.sale_line = Mock(
quantity=Decimal('12'),
quantity_theorical=None,
)
purchase_component = PurchaseComponent()
purchase_component.nbdays = None
purchase_component.line = Mock(
quantity=Decimal('15'),
quantity_theorical=None,
)
self.assertEqual(sale_component.get_quota_sale('quota_sale'), Decimal('12.0000'))
self.assertEqual(
purchase_component.get_quota_purchase('quota'),
Decimal('15.00000'))
def test_sale_and_purchase_generate_pricing_use_quantity_fallback(self):
'pricing generation uses quantity when theoretical quantity is missing'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
sale = Sale()
sale.id = 1
sale.quantity = Decimal('10')
sale.quantity_theorical = None
sale.unit = Mock()
sale.sale = Mock(currency=Mock())
sale.getnearprice = Mock(return_value=Decimal('0'))
purchase = Purchase()
purchase.id = 1
purchase.quantity = Decimal('12')
purchase.quantity_theorical = None
purchase.unit = Mock()
purchase.purchase = Mock(currency=Mock())
purchase.getnearprice = Mock(return_value=Decimal('0'))
pricing_model = Mock()
pricing_model.search.return_value = []
pc_sale = Mock(
id=1, quota_sale=Decimal('2'), pricing_date=None,
get_price=Mock(return_value=Decimal('1')))
pc_purchase = Mock(
id=1, quota=Decimal('3'), pricing_date=None,
get_price=Mock(return_value=Decimal('1')))
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool, patch(
'trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
SalePool.return_value.get.return_value = pricing_model
PurchasePool.return_value.get.return_value = pricing_model
sale.generate_pricing(pc_sale, [datetime.datetime(2026, 4, 1)], [])
purchase.generate_pricing(pc_purchase, [datetime.datetime(2026, 4, 1)], [])
self.assertEqual(pricing_model.save.call_args_list[0].args[0][0].unfixed_qt, Decimal('10'))
self.assertEqual(pricing_model.save.call_args_list[1].args[0][0].unfixed_qt, Decimal('12'))
def test_trade_line_quantity_is_readonly(self):
'trade line quantity is a technical counter, not an editable business input'
SaleLine = Pool().get('sale.line')
PurchaseLine = Pool().get('purchase.line')
self.assertTrue(SaleLine.quantity.readonly)
self.assertTrue(PurchaseLine.quantity.readonly)
def test_allow_modification_after_validation_line_readonly_rule(self):
'configuration flag unlocks lines only in quotation state'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
for model in (Sale, Purchase):
expression = model.lines.states['readonly']
self.assertTrue(PYSONDecoder({
'state': 'quotation',
'allow_modification_after_validation': False,
}).decode(expression))
self.assertFalse(PYSONDecoder({
'state': 'quotation',
'allow_modification_after_validation': True,
}).decode(expression))
self.assertTrue(PYSONDecoder({
'state': 'confirmed',
'allow_modification_after_validation': True,
}).decode(expression))
def test_purchase_line_initial_quantity_uses_theoretical_quantity(self):
'purchase line initializes technical quantity from contractual quantity'
PurchaseLine = Pool().get('purchase.line')
line = Mock(
quantity=None,
quantity_theorical=Decimal('42'),
lots=[],
)
self.assertTrue(PurchaseLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('42.00000'))
def test_sale_line_initial_quantity_uses_theoretical_quantity(self):
'sale line initializes technical quantity from contractual quantity'
SaleLine = Pool().get('sale.line')
line = Mock(
quantity=Decimal('0'),
quantity_theorical=Decimal('24'),
lots=[],
)
self.assertTrue(SaleLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('24.00000'))
def test_trade_line_initial_quantity_does_not_override_existing_counter(self):
'initial sync keeps an existing technical quantity'
PurchaseLine = Pool().get('purchase.line')
physical = Mock(lot_type='virtual')
line = Mock(
quantity=Decimal('10'),
quantity_theorical=Decimal('20'),
lots=[physical],
)
self.assertFalse(PurchaseLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('10'))
def test_trade_line_initial_quantity_does_not_run_with_physical_lot(self):
'initial sync never overwrites the executed physical quantity'
SaleLine = Pool().get('sale.line')
physical = Mock(lot_type='physic')
line = Mock(
quantity=Decimal('0'),
quantity_theorical=Decimal('20'),
lots=[physical],
)
self.assertFalse(SaleLine._sync_initial_quantity_from_theorical(line))
self.assertEqual(line.quantity, Decimal('0'))
def test_purchase_line_amount_uses_theoretical_quantity_until_finished(self):
'purchase line amount uses contractual quantity while line remains open'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = False
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = None
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
def test_purchase_line_amount_uses_physical_counter_when_finished(self):
'purchase line amount uses executed quantity once line is finished'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = None
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
def test_purchase_line_amount_uses_purchase_weight_basis_when_finished(self):
'purchase line amount uses purchase weight basis for physical lots'
PurchaseLine = Pool().get('purchase.line')
bl = Mock(id=1)
unit = Mock()
lot = Mock(
lot_type='physic',
lot_hist=[Mock(quantity_type=bl)],
)
lot.get_current_quantity_converted.return_value = Decimal('8')
line = PurchaseLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.purchase = Mock(wb=Mock(qt_type=bl))
line.unit = unit
line.lots = [lot]
self.assertEqual(line.on_change_with_amount(), Decimal('16'))
lot.get_current_quantity_converted.assert_called_once_with(1, unit)
def test_sale_line_amount_uses_theoretical_quantity_until_finished(self):
'sale line amount uses contractual quantity while line remains open'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = False
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = None
self.assertEqual(line.on_change_with_amount(), Decimal('20'))
def test_sale_line_amount_uses_physical_counter_when_finished(self):
'sale line amount uses executed quantity once line is finished'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('6')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = None
self.assertEqual(line.on_change_with_amount(), Decimal('12'))
def test_sale_line_amount_uses_sale_weight_basis_when_finished(self):
'sale line amount uses sale weight basis for physical lots'
SaleLine = Pool().get('sale.line')
bl = Mock(id=1)
lr = Mock(id=2)
unit = Mock()
lot = Mock(
lot_type='physic',
lot_hist=[Mock(quantity_type=bl), Mock(quantity_type=lr)],
)
lot.get_current_quantity_converted.return_value = Decimal('7')
line = SaleLine()
line.type = 'line'
line.quantity = Decimal('8')
line.quantity_theorical = Decimal('10')
line.finished = True
line.unit_price = Decimal('2')
line.premium = Decimal('0')
line.enable_linked_currency = False
line.linked_currency = None
line.sale = Mock(wb=Mock(qt_type=lr))
line.unit = unit
line.lots = [lot]
self.assertEqual(line.on_change_with_amount(), Decimal('14'))
lot.get_current_quantity_converted.assert_called_once_with(2, unit)
def test_pricing_manual_fields_are_editable_except_eod(self):
'manual pricing rows only edit quantity and settlement while derived values stay computed'
Pricing = Pool().get('pricing.pricing')
self.assertFalse(Pricing.quantity.readonly)
self.assertFalse(Pricing.settl_price.readonly)
self.assertTrue(Pricing.fixed_qt.readonly)
self.assertTrue(Pricing.fixed_qt_price.readonly)
self.assertTrue(Pricing.unfixed_qt.readonly)
self.assertTrue(Pricing.unfixed_qt_price.readonly)
self.assertTrue(Pricing.eod_price.readonly)
def test_pricing_component_domain_is_limited_to_owner_line(self):
'pricing component choices are limited to the purchase or sale line'
Pricing = Pool().get('pricing.pricing')
self.assertEqual(Pricing.price_component.domain, [
('id', 'in', Eval('available_components', [])),
])
def test_pricing_defaults_single_manual_purchase_component(self):
'new purchase pricing rows default the only manual line component'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
component = Mock(id=33, auto=False)
component_model = Mock()
component_model.search.return_value = [component]
pool = Mock()
pool.get.return_value = component_model
pricing = Pricing()
pricing.line = owner
pricing.sale_line = None
pricing.price_component = None
with patch('trytond.modules.purchase_trade.pricing.Pool',
return_value=pool):
pricing.on_change_line()
component_model.search.assert_called_once_with([('line', '=', owner)])
self.assertEqual(pricing.price_component, component)
def test_pricing_defaults_single_manual_sale_component(self):
'new sale pricing rows default the only manual line component'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
component = Mock(id=33, auto=False)
component_model = Mock()
component_model.search.return_value = [component]
pool = Mock()
pool.get.return_value = component_model
pricing = Pricing()
pricing.line = None
pricing.sale_line = owner
pricing.price_component = None
with patch('trytond.modules.purchase_trade.pricing.Pool',
return_value=pool):
pricing.on_change_sale_line()
component_model.search.assert_called_once_with([
('sale_line', '=', owner)])
self.assertEqual(pricing.price_component, component)
def test_pricing_does_not_default_single_auto_component(self):
'new manual pricing rows do not default an auto component'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
component_model = Mock()
component_model.search.return_value = [Mock(id=33, auto=True)]
pool = Mock()
pool.get.return_value = component_model
pricing = Pricing()
pricing.line = owner
pricing.sale_line = None
pricing.price_component = None
with patch('trytond.modules.purchase_trade.pricing.Pool',
return_value=pool):
pricing.on_change_line()
self.assertIsNone(pricing.price_component)
def test_pricing_component_default_triggers_copy_first_purchase_component(self):
'new purchase components inherit trigger values from the first component'
Component = Pool().get('pricing.component')
pricing_period = Mock(id=21)
application_period = Mock(id=22)
first_component = Mock(triggers=[
Mock(
pricing_period=pricing_period,
from_p=datetime.date(2026, 4, 1),
to_p=datetime.date(2026, 4, 5),
average=True,
last=False,
application_period=application_period,
from_a=datetime.date(2026, 5, 1),
to_a=datetime.date(2026, 5, 5),
),
])
with patch.object(Component, 'search',
return_value=[first_component]) as search:
values = Component._default_trigger_values(line=10)
search.assert_called_once_with(
[('line', '=', 10)], order=[('id', 'ASC')], limit=1)
self.assertEqual(values, [{
'pricing_period': 21,
'from_p': datetime.date(2026, 4, 1),
'to_p': datetime.date(2026, 4, 5),
'average': True,
'last': False,
'application_period': 22,
'from_a': datetime.date(2026, 5, 1),
'to_a': datetime.date(2026, 5, 5),
}])
def test_pricing_component_create_inherits_first_sale_component_triggers(self):
'component create copies triggers from the first sale line component'
Component = Pool().get('pricing.component')
first_component = Mock(triggers=[
Mock(
pricing_period=Mock(id=21),
from_p=datetime.date(2026, 4, 1),
to_p=datetime.date(2026, 4, 5),
average=True,
last=True,
application_period=None,
from_a=None,
to_a=None,
),
])
with patch.object(Component, 'search',
return_value=[first_component]) as search, patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Component.create([{'sale_line': 10, 'price_source_type': 'curve'}])
search.assert_called_once_with(
[('sale_line', '=', 10)], order=[('id', 'ASC')], limit=1)
created_values = super_mock.return_value.create.call_args.args[0][0]
self.assertEqual(created_values['triggers'], [('create', [{
'pricing_period': 21,
'from_p': datetime.date(2026, 4, 1),
'to_p': datetime.date(2026, 4, 5),
'average': True,
'last': True,
'application_period': None,
'from_a': None,
'to_a': None,
}])])
def test_pricing_component_create_keeps_explicit_triggers(self):
'component create does not replace triggers already provided'
Component = Pool().get('pricing.component')
explicit_triggers = [('create', [{'average': False}])]
with patch.object(Component, 'search') as search, patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Component.create([{
'line': 10,
'price_source_type': 'curve',
'triggers': explicit_triggers,
}])
search.assert_not_called()
created_values = super_mock.return_value.create.call_args.args[0][0]
self.assertIs(created_values['triggers'], explicit_triggers)
def test_pricing_component_must_belong_to_pricing_owner(self):
'pricing rows reject components from another purchase or sale line'
Pricing = Pool().get('pricing.pricing')
owner = Mock(id=10)
other = Mock(id=11)
self.assertTrue(Pricing._component_matches_owner(Mock(
line=owner,
sale_line=None,
price_component=Mock(line=owner, sale_line=None),
)))
self.assertFalse(Pricing._component_matches_owner(Mock(
line=owner,
sale_line=None,
price_component=Mock(line=other, sale_line=None),
)))
self.assertTrue(Pricing._component_matches_owner(Mock(
line=None,
sale_line=owner,
price_component=Mock(line=None, sale_line=owner),
)))
self.assertFalse(Pricing._component_matches_owner(Mock(
line=None,
sale_line=owner,
price_component=Mock(line=None, sale_line=other),
)))
def test_purchase_and_sale_delivery_period_reject_from_after_to(self):
'delivery period dates must be chronological on purchase and sale lines'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
invalid = Mock(
from_del=datetime.date(2026, 5, 1),
to_del=datetime.date(2026, 4, 30),
)
valid = Mock(
from_del=datetime.date(2026, 4, 1),
to_del=datetime.date(2026, 4, 30),
)
self.assertTrue(PurchaseLine._has_invalid_delivery_period(invalid))
self.assertTrue(SaleLine._has_invalid_delivery_period(invalid))
self.assertFalse(PurchaseLine._has_invalid_delivery_period(valid))
self.assertFalse(SaleLine._has_invalid_delivery_period(valid))
purchase_line = PurchaseLine()
purchase_line.from_del = invalid.from_del
purchase_line.to_del = invalid.to_del
sale_line = SaleLine()
sale_line.from_del = invalid.from_del
sale_line.to_del = invalid.to_del
with self.assertRaises(UserError):
purchase_line.on_change_from_del()
with self.assertRaises(UserError):
sale_line.on_change_to_del()
with self.assertRaises(UserError):
PurchaseLine._check_delivery_period_values([valid], {
'from_del': invalid.from_del,
})
with self.assertRaises(UserError):
SaleLine._check_delivery_period_values([valid], {
'to_del': datetime.date(2026, 3, 31),
})
with self.assertRaises(UserError):
PurchaseLine._check_delivery_period_values([invalid], {
'quantity': Decimal('1'),
})
with self.assertRaises(UserError):
SaleLine._check_delivery_period_values([invalid], {
'quantity': Decimal('1'),
})
def test_purchase_and_sale_line_create_default_estimated_bl_date(self):
'new lines default missing estimated BL date from delivery period start'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
period = Mock(beg_date=datetime.date(2026, 5, 1))
purchase_values = {'del_period': period}
sale_values = {'del_period': period}
PurchaseLine._set_default_estimated_bl_date_values(purchase_values)
SaleLine._set_default_estimated_bl_date_values(sale_values)
self.assertEqual(purchase_values['estimated_date'], [('create', [{
'trigger': 'bldate',
'estimated_date': datetime.date(2026, 5, 1),
}])])
self.assertEqual(sale_values['estimated_date'], [('create', [{
'trigger': 'bldate',
'estimated_date': datetime.date(2026, 5, 1),
}])])
def test_purchase_and_sale_line_default_estimated_bl_date_accepts_tuple(self):
'new lines normalize tuple estimated date commands before appending'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
period = Mock(beg_date=datetime.date(2026, 5, 1))
existing = ('create', [{
'trigger': 'other',
'estimated_date': datetime.date(2026, 5, 9),
}])
purchase_values = {
'del_period': period,
'estimated_date': (existing,),
}
sale_values = {
'del_period': period,
'estimated_date': (existing,),
}
PurchaseLine._set_default_estimated_bl_date_values(purchase_values)
SaleLine._set_default_estimated_bl_date_values(sale_values)
expected = [existing, ('create', [{
'trigger': 'bldate',
'estimated_date': datetime.date(2026, 5, 1),
}])]
self.assertEqual(purchase_values['estimated_date'], expected)
self.assertEqual(sale_values['estimated_date'], expected)
def test_purchase_line_reporting_parent_fields_and_counts(self):
'purchase line reporting fields mirror parent values and relation counts'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.purchase = Mock(
trader=Mock(id=10),
operator=Mock(id=11),
broker=Mock(id=12),
contract_template=Mock(id=13),
our_reference='OUR-PUR',
lc_date=datetime.date(2026, 5, 2),
product_origin='Origin',
)
line.price_components = [Mock(), Mock()]
line.price_pricing = [Mock()]
line.mtm = [Mock(), Mock(), Mock()]
line.lots = []
self.assertEqual(line.get_parent_field('parent_trader'), 10)
self.assertEqual(line.get_parent_field('parent_operator'), 11)
self.assertEqual(
line.get_parent_field('parent_contract_template'), 13)
self.assertEqual(line.get_parent_field('parent_our_reference'), 'OUR-PUR')
self.assertEqual(
line.get_relation_count('price_component_count'), 2)
self.assertEqual(line.get_relation_count('pricing_count'), 1)
self.assertEqual(line.get_relation_count('mtm_count'), 3)
self.assertEqual(line.get_relation_count('lot_count'), 0)
def test_sale_line_reporting_parent_fields_and_counts(self):
'sale line reporting fields mirror parent values and relation counts'
SaleLine = Pool().get('sale.line')
line = SaleLine()
line.sale = Mock(
trader=Mock(id=20),
operator=Mock(id=21),
invoice_party=Mock(id=22),
shipment_party=Mock(id=23),
contract_template=Mock(id=24),
our_reference='OUR-SALE',
lc_date=datetime.date(2026, 5, 3),
product_origin='Origin',
)
line.price_components = [Mock()]
line.price_composition = [Mock(), Mock()]
line.price_pricing = []
line.fees = [Mock(), Mock(), Mock()]
self.assertEqual(line.get_parent_field('parent_trader'), 20)
self.assertEqual(line.get_parent_field('parent_operator'), 21)
self.assertEqual(line.get_parent_field('parent_invoice_party'), 22)
self.assertEqual(line.get_parent_field('parent_shipment_party'), 23)
self.assertEqual(line.get_parent_field('parent_our_reference'), 'OUR-SALE')
self.assertEqual(
line.get_relation_count('price_component_count'), 1)
self.assertEqual(
line.get_relation_count('price_composition_count'), 2)
self.assertEqual(line.get_relation_count('pricing_count'), 0)
self.assertEqual(line.get_relation_count('fee_count'), 3)
def test_purchase_and_sale_line_keep_explicit_estimated_bl_date(self):
'new lines keep explicit estimated BL date'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
period = Mock(beg_date=datetime.date(2026, 5, 1))
explicit = [('create', [{
'trigger': 'bldate',
'estimated_date': datetime.date(2026, 5, 9),
}])]
purchase_values = {'del_period': period, 'estimated_date': list(explicit)}
sale_values = {'del_period': period, 'estimated_date': list(explicit)}
PurchaseLine._set_default_estimated_bl_date_values(purchase_values)
SaleLine._set_default_estimated_bl_date_values(sale_values)
self.assertEqual(purchase_values['estimated_date'], explicit)
self.assertEqual(sale_values['estimated_date'], explicit)
def test_purchase_line_write_creates_missing_estimated_bl_date(self):
'purchase line write creates missing estimated BL date from period'
PurchaseLine = Pool().get('purchase.line')
line = Mock(
id=42,
del_period=Mock(beg_date=datetime.date(2026, 5, 1)),
estimated_date=[],
)
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
Estimated = Mock()
PoolMock.return_value.get.return_value = Estimated
PurchaseLine._create_missing_estimated_bl_dates([line])
Estimated.create.assert_called_once_with([{
'line': 42,
'trigger': 'bldate',
'estimated_date': datetime.date(2026, 5, 1),
}])
def test_sale_line_write_creates_missing_estimated_bl_date(self):
'sale line write creates missing estimated BL date from period'
SaleLine = Pool().get('sale.line')
line = Mock(
id=43,
del_period=Mock(beg_date=datetime.date(2026, 5, 1)),
estimated_date=[],
)
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
Estimated = Mock()
PoolMock.return_value.get.return_value = Estimated
SaleLine._create_missing_estimated_bl_dates([line])
Estimated.create.assert_called_once_with([{
'sale_line': 43,
'trigger': 'bldate',
'estimated_date': datetime.date(2026, 5, 1),
}])
def test_sale_and_purchase_parent_write_check_embedded_line_commands(self):
'sale and purchase writes validate embedded one2many line commands'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
invalid = Mock(
from_del=datetime.date(2026, 4, 16),
to_del=datetime.date(2026, 4, 10),
)
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool:
SalePool.return_value.get.return_value = Mock(
browse=Mock(return_value=[invalid]))
with self.assertRaises(UserError):
Sale._check_lines_delivery_period_values({
'lines': [['write', [564], {'quantity': Decimal('1')}]],
})
with patch('trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
PurchasePool.return_value.get.return_value = Mock(
browse=Mock(return_value=[invalid]))
with self.assertRaises(UserError):
Purchase._check_lines_delivery_period_values({
'lines': [['add', [564]]],
})
def test_parent_write_checks_final_embedded_line_values(self):
'parent writes validate final line values after add and write commands'
Sale = Pool().get('sale.sale')
line = Mock(
from_del=datetime.date(2026, 4, 21),
to_del=datetime.date(2026, 4, 10),
)
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool:
SalePool.return_value.get.return_value = Mock(
browse=Mock(return_value=[line]))
Sale._check_lines_delivery_period_values({
'lines': [
['add', [564]],
['write', [564], {
'from_del': datetime.date(2026, 4, 6),
}],
],
})
def test_pricing_eod_uses_weighted_average_for_manual_rows(self):
'manual pricing eod uses the weighted average of fixed and unfixed legs'
Pricing = Pool().get('pricing.pricing')
pricing = Pricing()
pricing.fixed_qt = Decimal('4')
pricing.fixed_qt_price = Decimal('100')
pricing.unfixed_qt = Decimal('6')
pricing.unfixed_qt_price = Decimal('110')
self.assertEqual(pricing.compute_eod_price(), Decimal('106.0000'))
def test_sale_and_purchase_eod_use_same_weighted_formula(self):
'auto sale/purchase eod helpers use the same weighted average formula'
Pricing = Pool().get('pricing.pricing')
sale_pricing = Pricing()
sale_pricing.sale_line = Mock(quantity=Decimal('999'))
sale_pricing.fixed_qt = Decimal('4')
sale_pricing.fixed_qt_price = Decimal('100')
sale_pricing.unfixed_qt = Decimal('6')
sale_pricing.unfixed_qt_price = Decimal('110')
purchase_pricing = Pricing()
purchase_pricing.line = Mock(quantity_theorical=Decimal('999'))
purchase_pricing.fixed_qt = Decimal('4')
purchase_pricing.fixed_qt_price = Decimal('100')
purchase_pricing.unfixed_qt = Decimal('6')
purchase_pricing.unfixed_qt_price = Decimal('110')
self.assertEqual(sale_pricing.get_eod_price_sale(), Decimal('106.0000'))
self.assertEqual(
purchase_pricing.get_eod_price_purchase(), Decimal('106.0000'))
def test_pricing_sync_manual_last_uses_greatest_date_per_component_group(self):
'pricing rows keep one last by line/component, chosen by greatest pricing date'
Pricing = Pool().get('pricing.pricing')
sale_line = Mock(id=10)
component = Mock(id=33)
first = Mock(
id=1,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 10),
last=True,
)
second = Mock(
id=2,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 9),
last=False,
)
with patch.object(Pricing, 'search', return_value=[second, first]), patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Pricing._sync_manual_last([first, second])
self.assertEqual(super_mock.return_value.write.call_args_list[0].args[0], [second])
self.assertEqual(super_mock.return_value.write.call_args_list[0].args[1], {'last': False})
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[0], [first])
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[1], {'last': True})
def test_pricing_sync_manual_values_recomputes_manual_group_from_quantity_and_settl(self):
'manual pricing rows derive fixed/unfixed values from quantity and settlement price'
Pricing = Pool().get('pricing.pricing')
sale_line = Mock(id=10)
sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10'))
component = Mock(id=33, auto=False)
first = Mock(
id=1,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 10),
quantity=Decimal('4'),
settl_price=Decimal('100'),
)
second = Mock(
id=2,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 11),
quantity=Decimal('3'),
settl_price=Decimal('110'),
)
with patch.object(Pricing, 'search', return_value=[first, second]), patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Pricing._sync_manual_values([first, second])
first_values = super_mock.return_value.write.call_args_list[0].args[1]
self.assertEqual(first_values['fixed_qt'], Decimal('4'))
self.assertEqual(first_values['fixed_qt_price'], Decimal('100.0000'))
self.assertEqual(first_values['unfixed_qt'], Decimal('6'))
self.assertEqual(first_values['unfixed_qt_price'], Decimal('100'))
self.assertEqual(first_values['eod_price'], Decimal('100.0000'))
self.assertFalse(first_values['last'])
second_values = super_mock.return_value.write.call_args_list[1].args[1]
self.assertEqual(second_values['fixed_qt'], Decimal('7'))
self.assertEqual(second_values['fixed_qt_price'], Decimal('104.2857'))
self.assertEqual(second_values['unfixed_qt'], Decimal('3'))
self.assertEqual(second_values['unfixed_qt_price'], Decimal('110'))
self.assertEqual(second_values['eod_price'], Decimal('106.0000'))
self.assertTrue(second_values['last'])
def test_pricing_sync_manual_values_uses_curve_last_price_for_unfixed(self):
'manual pricing rows use the component curve latest price for the unfixed leg'
Pricing = Pool().get('pricing.pricing')
currency = Mock()
unit = Mock()
sale_line = Mock(
id=10,
sale=Mock(currency=currency),
unit=unit,
)
sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10'))
component = Mock(
id=33,
auto=False,
price_source_type='curve',
price_index=Mock(),
)
component.get_price.return_value = Decimal('120')
pricing = Mock(
id=1,
price_component=component,
sale_line=sale_line,
line=None,
pricing_date=datetime.date(2026, 4, 10),
quantity=Decimal('4'),
settl_price=Decimal('100'),
)
with patch.object(Pricing, 'search', return_value=[pricing]), patch(
'trytond.modules.purchase_trade.pricing.super') as super_mock:
Pricing._sync_manual_values([pricing])
values = super_mock.return_value.write.call_args.args[1]
self.assertEqual(values['unfixed_qt_price'], Decimal('120.0000'))
self.assertEqual(values['eod_price'], Decimal('112.0000'))
component.get_price.assert_called_once_with(
datetime.date(2026, 4, 10), unit, currency, True)
def test_pricing_trigger_manual_from_to_generates_dates_without_calendar(self):
'manual pricing From/To uses calendar days when no calendar is selected'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.pricing_period = None
trigger.from_p = datetime.date(2026, 4, 1)
trigger.to_p = datetime.date(2026, 4, 3)
trigger.getprice = Mock(side_effect=lambda date: {
'date': date,
'price': Decimal('100'),
'avg': Decimal('100'),
'avg_minus_1': Decimal('100'),
'isAvg': False,
})
dates, prices = trigger.getPricingListDates(None)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 1),
datetime.date(2026, 4, 2),
datetime.date(2026, 4, 3),
])
self.assertEqual(len(prices), 3)
def test_pricing_period_every_positive_returns_weekdays_after_trigger(self):
'Every Friday and 4 returns the four Fridays from/after trigger date'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.every = 'friday'
period.nb_quotation_after = 4
_, _, dates = period.getDates(datetime.date(2026, 4, 1))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 3),
datetime.date(2026, 4, 10),
datetime.date(2026, 4, 17),
datetime.date(2026, 4, 24),
])
def test_pricing_period_every_before_returns_weekdays_before_trigger(self):
'Every Friday with before returns the four Fridays before trigger date'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.every = 'friday'
period.nb_quotation_before = 4
_, _, dates = period.getDates(datetime.date(2026, 4, 30))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 3),
datetime.date(2026, 4, 10),
datetime.date(2026, 4, 17),
datetime.date(2026, 4, 24),
])
def test_pricing_period_blank_every_after_uses_available_dates_after_trigger(self):
'Blank Every and Nb quotes after returns dates after trigger'
Period = Pool().get('pricing.period')
calendar = Mock()
excluded = {
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 16),
}
calendar.IsQuote = Mock(
side_effect=lambda date: date.date() not in excluded)
period = Period()
period.trigger = 'bldate'
period.every = None
period.nb_quotation_after = 4
_, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 15),
datetime.date(2026, 5, 17),
datetime.date(2026, 5, 18),
])
def test_pricing_period_blank_every_include_counts_trigger_plus_after_dates(self):
'Blank Every with Inc. returns trigger date plus quotes after'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.include = True
period.every = None
period.nb_quotation_after = 4
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 12),
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 15),
datetime.date(2026, 5, 16),
])
def test_pricing_period_blank_every_before_uses_available_dates_before_trigger(self):
'Blank Every and Nb quotes before returns dates before trigger'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.every = None
period.nb_quotation_before = 4
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 8),
datetime.date(2026, 5, 9),
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 11),
])
def test_pricing_period_blank_every_returns_asymmetric_dates(self):
'Blank Every returns independent quotations before and after trigger'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.include = False
period.every = None
period.nb_quotation_before = 1
period.nb_quotation_after = 3
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 11),
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 15),
])
def test_pricing_period_blank_every_before_and_after_include_adds_trigger_date(self):
'Blank Every with Inc. adds trigger date between both sides'
Period = Pool().get('pricing.period')
period = Period()
period.trigger = 'bldate'
period.include = True
period.every = None
period.nb_quotation_before = 2
period.nb_quotation_after = 2
_, _, dates = period.getDates(datetime.date(2026, 5, 12))
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 11),
datetime.date(2026, 5, 12),
datetime.date(2026, 5, 13),
datetime.date(2026, 5, 14),
])
def test_pricing_period_blank_every_before_and_after_skips_calendar_exclusions(self):
'Blank Every uses available quotations around trigger date'
Period = Pool().get('pricing.period')
calendar = Mock()
excluded = {
datetime.date(2026, 5, 11),
datetime.date(2026, 5, 13),
}
calendar.IsQuote = Mock(
side_effect=lambda date: date.date() not in excluded)
period = Period()
period.trigger = 'bldate'
period.include = True
period.every = None
period.nb_quotation_before = 1
period.nb_quotation_after = 2
_, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 5, 10),
datetime.date(2026, 5, 12),
datetime.date(2026, 5, 14),
datetime.date(2026, 5, 15),
])
def test_pricing_trigger_application_period_defaults_to_pricing_period(self):
'application period defaults to pricing period without being cleared'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.pricing_period = Mock(id=21)
trigger.application_period = None
self.assertEqual(
trigger.on_change_with_application_period(),
trigger.pricing_period)
def test_pricing_trigger_application_period_keeps_existing_value(self):
'application period keeps explicit value when pricing period changes'
Trigger = Pool().get('pricing.trigger')
application_period = Mock(id=22)
trigger = Trigger()
trigger.pricing_period = Mock(id=21)
trigger.application_period = application_period
self.assertEqual(
trigger.on_change_with_application_period(),
application_period)
def test_purchase_line_get_avg_accepts_decimal_matrix_prices(self):
'purchase pricing averages Decimal matrix prices without float mixing'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
prices = [
{'price': Decimal('45.0000')},
{'price': Decimal('55.0000')},
]
result = line.get_avg(prices)
self.assertEqual(result[0]['avg'], Decimal('45.0000'))
self.assertEqual(result[1]['avg_minus_1'], Decimal('45.0000'))
self.assertEqual(result[1]['avg'], Decimal('50.0000'))
def test_sale_line_get_avg_accepts_decimal_matrix_prices(self):
'sale pricing averages Decimal matrix prices without float mixing'
SaleLine = Pool().get('sale.line')
line = SaleLine()
prices = [
{'price': Decimal('45.0000')},
{'price': Decimal('55.0000')},
]
result = line.get_avg(prices)
self.assertEqual(result[0]['avg'], Decimal('45.0000'))
self.assertEqual(result[1]['avg_minus_1'], Decimal('45.0000'))
self.assertEqual(result[1]['avg'], Decimal('50.0000'))
def test_pricing_trigger_manual_application_defaults_to_pricing_from_to(self):
'empty manual application From/To falls back to manual pricing From/To'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.pricing_period = None
trigger.application_period = None
trigger.from_p = datetime.date(2026, 4, 1)
trigger.to_p = datetime.date(2026, 4, 2)
trigger.from_a = None
trigger.to_a = None
dates, prices = trigger.getApplicationListDates(None)
self.assertEqual(
[date.date() for date in dates],
[
datetime.date(2026, 4, 1),
datetime.date(2026, 4, 2),
])
self.assertEqual(prices, [])
def test_pricing_trigger_on_change_copies_manual_pricing_dates_to_application(self):
'manual pricing From/To is copied to empty application From/To in the UI'
Trigger = Pool().get('pricing.trigger')
trigger = Trigger()
trigger.application_period = None
trigger.from_p = datetime.date(2026, 4, 1)
trigger.to_p = datetime.date(2026, 4, 30)
trigger.from_a = None
trigger.to_a = None
trigger.on_change_from_p()
trigger.on_change_to_p()
self.assertEqual(trigger.from_a, datetime.date(2026, 4, 1))
self.assertEqual(trigger.to_a, datetime.date(2026, 4, 30))
def test_pricing_trigger_average_defaults_to_true_and_last_modes(self):
'pricing trigger defaults to Avg and exposes AL/RL last modes'
Trigger = Pool().get('pricing.trigger')
self.assertTrue(Trigger.default_average())
self.assertEqual(Trigger.last.string, 'AL')
self.assertEqual(Trigger.relative_last.string, 'RL')
@with_transaction()
def test_import_forward_prices_copies_value_to_ohl_fields(self):
'forward import uses price values for open, low, mid and high prices'
ImportPrices = Pool().get('purchase_trade.import_prices')
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheet = ElementTree.fromstring('''
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
<c r="C1" t="inlineStr"><is><t>2026-03</t></is></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>ICE Cotton</t></is></c>
<c r="B2" t="inlineStr"><is><t>2026-03-01</t></is></c>
<c r="C2"><v>75.25</v></c>
</row>
</sheetData>
</worksheet>
''')
rows = sheet.findall('.//s:sheetData/s:row', ns)
result = ImportPrices._read_forward_price_rows(rows, [], ns)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['price_value'], '75.25')
self.assertEqual(result[0]['open_price'], '75.25')
self.assertEqual(result[0]['low_price'], '75.25')
self.assertEqual(result[0]['mid_price'], '75.25')
self.assertEqual(result[0]['high_price'], '75.25')
def test_import_historical_prices_computes_mid_from_low_high(self):
'historical import computes mid price from low and high prices'
ImportPrices = Pool().get('purchase_trade.import_prices')
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheet = ElementTree.fromstring('''
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
<c r="C1" t="inlineStr"><is><t>High Price</t></is></c>
<c r="D1" t="inlineStr"><is><t>Low Price</t></is></c>
<c r="E1" t="inlineStr"><is><t>Open Price</t></is></c>
<c r="F1" t="inlineStr"><is><t>Price Value</t></is></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>ARGUS-CFR Brasil</t></is></c>
<c r="B2"><v>46191</v></c>
<c r="C2"><v>482</v></c>
<c r="D2"><v>460</v></c>
<c r="E2"><v>470</v></c>
<c r="F2"><v>470</v></c>
</row>
</sheetData>
</worksheet>
''')
rows = sheet.findall('.//s:sheetData/s:row', ns)
result = ImportPrices._read_historical_price_rows(rows, [], ns)
values = ImportPrices._price_value_values(
Mock(id=1), result[0], datetime.date(2026, 6, 18))
self.assertNotIn('mid_price', result[0])
self.assertEqual(values['mid_price'], 471)
def test_import_forward_price_values_compute_mid_from_low_high(self):
'forward import value builder computes mid price from low and high'
ImportPrices = Pool().get('purchase_trade.import_prices')
values = ImportPrices._price_value_values(
Mock(id=1), {
'high_price': '482',
'low_price': '460',
'mid_price': '0',
'open_price': '470',
'price_value': '470',
},
datetime.date(2026, 6, 18))
self.assertEqual(values['mid_price'], 471)
@with_transaction()
def test_import_historical_all_sources_uses_mapping_table(self):
'all sources import maps source columns to standard price values'
ImportPrices = Pool().get('purchase_trade.import_prices')
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
sheet = ElementTree.fromstring('''
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>Copyright</t></is></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>combined_x000D_
description</t></is></c>
<c r="B2" t="inlineStr"><is><t>Original Mid</t></is></c>
<c r="C2" t="inlineStr"><is><t>Original High</t></is></c>
<c r="D2" t="inlineStr"><is><t>Original Low</t></is></c>
</row>
<row r="3">
<c r="A3"><v>46191</v></c>
<c r="B3"><v>470</v></c>
<c r="C3"><v>482</v></c>
<c r="D3"><v>460</v></c>
</row>
</sheetData>
</worksheet>
''')
rows = sheet.findall('.//s:sheetData/s:row', ns)
mapping = [
{
'excel_tab': 'Argus Download',
'original_price_index': 'Original Mid',
'source': 'ARGUS',
'column_destination': 'mid_price',
'standard_price_index': 'ARGUS-CFR Brasil',
'index_description': 'Brazil CFR',
},
{
'excel_tab': 'Argus Download',
'original_price_index': 'Original High',
'source': 'ARGUS',
'column_destination': 'high_price',
'standard_price_index': 'ARGUS-CFR Brasil',
'index_description': 'Brazil CFR',
},
{
'excel_tab': 'Argus Download',
'original_price_index': 'Original Low',
'source': 'ARGUS',
'column_destination': 'low_price',
'standard_price_index': 'ARGUS-CFR Brasil',
'index_description': 'Brazil CFR',
},
{
'excel_tab': 'Acuity Download',
'original_price_index': 'Missing Header',
'source': 'ACUITY',
'column_destination': 'mid_price',
'standard_price_index': 'ACUITY-Missing',
'index_description': 'Missing',
},
]
result = ImportPrices._read_historical_all_sources_rows(
{'Argus Download': rows}, mapping, [], ns)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['price_index'], 'ARGUS-CFR Brasil')
self.assertEqual(result[0]['price_date'], '46191')
self.assertEqual(result[0]['mid_price'], '470')
self.assertEqual(result[0]['price_value'], '470')
self.assertEqual(result[0]['open_price'], '470')
self.assertEqual(result[0]['high_price'], '482')
self.assertEqual(result[0]['low_price'], '460')
self.assertEqual(result[0]['_source'], 'ARGUS')
self.assertEqual(result[0]['_price_desc'], 'Brazil CFR')
values = ImportPrices._price_value_values(
Mock(id=1), result[0], datetime.date(2026, 6, 18))
self.assertEqual(values['mid_price'], 470)
def test_import_xlsx_uses_selected_parser(self):
'price import selects parser from requested file structure'
ImportPrices = Pool().get('purchase_trade.import_prices')
workbook = BytesIO()
with zipfile.ZipFile(workbook, 'w') as archive:
archive.writestr('xl/workbook.xml', '''
<workbook
xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Prices" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>
''')
archive.writestr('xl/_rels/workbook.xml.rels', '''
<Relationships
xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Target="worksheets/sheet1.xml"/>
</Relationships>
''')
archive.writestr('xl/worksheets/sheet1.xml', '''
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>Price Index</t></is></c>
<c r="B1" t="inlineStr"><is><t>Price Date</t></is></c>
</row>
<row r="2">
<c r="A2" t="inlineStr"><is><t>ARGUS-CFR Brasil</t></is></c>
<c r="B2"><v>46191</v></c>
</row>
</sheetData>
</worksheet>
''')
data = workbook.getvalue()
with patch.object(
ImportPrices, '_read_historical_price_rows',
return_value=['historical']) as historical, \
patch.object(
ImportPrices, '_read_historical_all_sources_price_rows',
return_value=['all_sources']) as all_sources, \
patch.object(
ImportPrices, '_read_forward_price_rows',
return_value=['forward']) as forward:
self.assertEqual(
ImportPrices._read_xlsx(
data, file_structure='historical'),
['historical'])
self.assertEqual(
ImportPrices._read_xlsx(
data, file_structure='historical_all_sources'),
['all_sources'])
self.assertEqual(
ImportPrices._read_xlsx(data, file_structure='forward'),
['forward'])
self.assertEqual(historical.call_count, 1)
self.assertEqual(all_sources.call_count, 1)
self.assertEqual(forward.call_count, 1)
def test_import_historical_all_sources_requires_exact_tab_match(self):
'all sources import ignores similarly named source sheets'
ImportPrices = Pool().get('purchase_trade.import_prices')
result = ImportPrices._matching_sheet_name(
{
'Acuity Download CODELCO': [],
},
'Acuity Download')
self.assertIsNone(result)
def test_import_price_index_values_uses_mapping_metadata(self):
'created price indexes use mapped description and source area'
ImportPrices = Pool().get('purchase_trade.import_prices')
area = Mock(id=8)
with patch.object(
ImportPrices, '_first_record',
side_effect=lambda model, domain: (
area if model == 'price.area' else None)):
values = ImportPrices._price_index_values(
'ARGUS-CFR Brasil',
{'_price_desc': 'Brazil CFR', '_source': 'ARGUS'})
self.assertEqual(values['price_index'], 'ARGUS-CFR Brasil')
self.assertEqual(values['price_desc'], 'Brazil CFR')
self.assertEqual(values['price_area'], 8)
def test_import_price_date_accepts_dot_format(self):
'price import accepts dd.mm.yyyy dates'
ImportPrices = Pool().get('purchase_trade.import_prices')
result = ImportPrices._as_date('18.06.2026')
self.assertEqual(result, datetime.date(2026, 6, 18))
def test_pricing_component_matrix_returns_generic_line_price(self):
'matrix pricing can use an unconditional matrix line as a component price'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'matrix'
component.price_matrix = Mock(
id=10,
valid_from=datetime.date(2026, 1, 1),
valid_to=datetime.date(2026, 12, 31),
unit=None,
currency=None,
)
component.line = Mock(
product=Mock(),
purchase=Mock(from_location=Mock(), to_location=Mock()))
component.sale_line = None
matrix_line = Mock(
origin=None,
destination=None,
product=None,
quality=None,
price_value=Decimal('45'))
matrix_model = Mock(search=Mock(return_value=[matrix_line]))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = matrix_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False)
self.assertEqual(price, Decimal('45.0000'))
def test_pricing_component_curve_passes_relative_last(self):
'curve pricing can request the price relative last before target date'
Component = Pool().get('pricing.component')
price_index = Mock()
component = Component()
component.price_source_type = 'curve'
component.price_index = price_index
price_model = Mock(return_value=price_index)
price_index.get_price = Mock(return_value=Decimal('101'))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = price_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False, True)
self.assertEqual(price, Decimal('101'))
price_index.get_price.assert_called_once_with(
datetime.date(2026, 4, 1), ANY, ANY, False, True)
def test_price_get_price_relative_last_uses_latest_before_date(self):
'relative last picks latest available price before target date'
Price = Pool().get('price.price')
price = Price()
price.id = 42
price.price_values = [Mock()]
price.get_price_per_qt = Mock(return_value=Decimal('88'))
previous_value = Mock(price_value=Decimal('88'))
price_value_model = Mock()
price_value_model.search = Mock(side_effect=[[], [previous_value]])
with patch('trytond.modules.price.price.Pool') as PricePool:
PricePool.return_value.get.return_value = price_value_model
result = price.get_price(
datetime.date(2026, 4, 10), Mock(), Mock(),
last=False, relative_last=True)
self.assertEqual(result, Decimal('88'))
price_value_model.search.assert_any_call([
('price', '=', 42),
('price_date', '<=', '2026-04-10'),
], order=[('price_date', 'DESC')])
def test_price_get_price_per_qt_converts_linked_currency_to_main_currency(self):
'price curve values in linked currency are normalized to main currency'
Price = Pool().get('price.price')
price = Price()
usd = SimpleNamespace(id=1)
price.price_currency = usd
price.enable_linked_currency = True
price.linked_currency = SimpleNamespace(factor=Decimal('0.01'))
price.price_unit = SimpleNamespace()
uom_model = Mock(compute_qty=Mock(return_value=1))
currency_model = Mock()
def get_model(name):
if name == 'product.uom':
return uom_model
if name == 'currency.currency':
return currency_model
return Mock()
with patch('trytond.modules.price.price.Pool') as PricePool:
PricePool.return_value.get.side_effect = get_model
result = price.get_price_per_qt(Decimal('72.5'), Mock(), usd)
self.assertEqual(result, Decimal('0.7250'))
currency_model._get_rate.assert_not_called()
def test_price_get_price_applies_linked_currency_conversion(self):
'market price lookup applies linked currency conversion'
Price = Pool().get('price.price')
price = Price()
price.id = 42
usd = SimpleNamespace(id=1)
price.price_currency = usd
price.enable_linked_currency = True
price.linked_currency = SimpleNamespace(factor=Decimal('0.01'))
price.price_unit = SimpleNamespace()
price.price_values = [Mock()]
price_value = Mock(price_value=Decimal('72.5'))
price_value_model = Mock(
search=Mock(return_value=[price_value]))
uom_model = Mock(compute_qty=Mock(return_value=1))
def get_model(name):
if name == 'price.price_value':
return price_value_model
if name == 'product.uom':
return uom_model
if name == 'currency.currency':
return Mock()
return Mock()
with patch('trytond.modules.price.price.Pool') as PricePool:
PricePool.return_value.get.side_effect = get_model
result = price.get_price(
datetime.date(2026, 4, 10), Mock(), usd, last=True)
self.assertEqual(result, Decimal('0.7250'))
def test_pricing_component_matrix_prefers_specific_line_over_generic(self):
'matrix pricing keeps generic lines as fallback below matching lines'
Component = Pool().get('pricing.component')
origin = Mock(id=1)
component = Component()
component.price_source_type = 'matrix'
component.price_matrix = Mock(
id=10,
valid_from=None,
valid_to=None,
unit=None,
currency=None,
)
component.line = Mock(
product=Mock(id=20),
purchase=Mock(from_location=origin, to_location=Mock(id=2)))
component.sale_line = None
generic = Mock(
origin=None,
destination=None,
product=None,
quality=None,
price_value=Decimal('45'))
specific = Mock(
origin=origin,
destination=None,
product=None,
quality=None,
price_value=Decimal('43'))
matrix_model = Mock(search=Mock(return_value=[generic, specific]))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = matrix_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False)
self.assertEqual(price, Decimal('43.0000'))
def test_pricing_component_matrix_uses_matrix_calendar_fallback(self):
'matrix calendar is used when the component has no own calendar'
Component = Pool().get('pricing.component')
calendar = Mock()
component = Component()
component.calendar = None
component.price_matrix = Mock(calendar=calendar)
self.assertEqual(component.get_calendar(), calendar)
def test_pricing_component_fixed_returns_flat_price(self):
'fixed pricing returns the component flat price'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'fixed'
component.fixed_price = Decimal('125.25')
currency = Mock(id=1)
component.fixed_currency = currency
self.assertEqual(
component.get_price(
datetime.date(2026, 4, 1), Mock(), currency, False),
Decimal('125.2500'))
def test_pricing_component_fixed_converts_currency(self):
'fixed pricing converts from component currency to requested currency'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'fixed'
component.fixed_price = Decimal('100')
component.fixed_currency = Mock(id=1)
target_currency = Mock(id=2)
currency_model = Mock(
_get_rate=Mock(return_value={
component.fixed_currency.id: Decimal('1.2')}))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = currency_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), target_currency, False)
self.assertEqual(price, Decimal('120.0000'))
def test_sale_and_purchase_trader_operator_domains_use_explicit_categories(self):
'sale and purchase trader/operator fields are filtered by TRADER/OPERATOR categories'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
ShipmentIn = Pool().get('stock.shipment.in')
self.assertEqual(
Sale.trader.domain, [('categories.name', '=', 'TRADER')])
self.assertEqual(
Sale.operator.domain, [('categories.name', '=', 'OPERATOR')])
self.assertEqual(
Purchase.trader.domain, [('categories.name', '=', 'TRADER')])
self.assertEqual(
Purchase.operator.domain, [('categories.name', '=', 'OPERATOR')])
self.assertEqual(
ShipmentIn.operator.domain, [('categories.name', '=', 'OPERATOR')])
def test_purchase_form_has_pnl_graph_tab_after_pnl(self):
'purchase form exposes a rich graph for historical PnL and MTM'
path = Path('modules/purchase_trade/view/purchase_form.xml')
root = ElementTree.parse(path).getroot()
pages = root.findall(".//xpath[@expr=\"/form/notebook/page[@id='info']\"]//page")
page_ids = [page.get('id') for page in pages]
pnl_index = page_ids.index('pnl')
self.assertEqual(page_ids[pnl_index + 1], 'pnl_graph')
graph_field = pages[pnl_index + 1].find("field[@name='pnl_graph']")
self.assertIsNotNone(graph_field)
self.assertEqual(graph_field.get('widget'), 'rich_graph')
self.assertEqual(graph_field.get('records_field'), 'pnl_graph')
self.assertEqual(graph_field.get('date_field'), 'valuation_date')
self.assertEqual(graph_field.get('series'), 'pnl,mtm')
self.assertEqual(
graph_field.get('view_ids'),
'purchase_trade.purchase_pnl_graph_view_tree')
self.assertEqual(graph_field.get('labels'), 'pnl:Pnl,mtm:Mtm')
self.assertEqual(graph_field.get('hidden_series'), 'pnl_graph')
value_fields = pages[pnl_index + 1].findall("field[@name='pnl_graph']")
self.assertEqual(value_fields[1].get('mode'), 'tree')
self.assertEqual(
value_fields[1].get('view_ids'),
'purchase_trade.purchase_pnl_graph_view_tree')
def test_shipment_in_pnl_lines_use_physical_and_open_lots(self):
'shipment PnL uses valuation lines from physical and open lot links'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.id = 99
physical_lot = Mock(id=1)
open_purchase_lot = Mock(id=2)
open_sale_lot = Mock(id=3)
shipment.incoming_moves = [
Mock(lot=physical_lot),
Mock(lot=None),
]
shipment.lotqt = [
Mock(lot_p=open_purchase_lot, lot_s=open_sale_lot),
Mock(lot_p=open_purchase_lot, lot_s=None),
]
valuation_model = Mock()
valuation_model.search.return_value = [Mock(id=10), Mock(id=11)]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = valuation_model
self.assertEqual(shipment.get_pnl_lines(None), [10, 11])
valuation_model.search.assert_called_once_with(
[('shipment_in', '=', 99)],
order=[('date', 'DESC'), ('id', 'DESC')])
def test_sale_and_purchase_tolerance_option_defaults_to_empty(self):
'sale and purchase tolerance option defaults to empty'
Sale = Pool().get('sale.sale')
Purchase = Pool().get('purchase.purchase')
self.assertEqual(Sale.default_tolerance_option(), '')
self.assertEqual(Purchase.default_tolerance_option(), '')
def test_purchase_line_premium_sums_decomposition_base_amounts(self):
'purchase line premium is the sum of premium decomposition base amounts'
Line = Pool().get('purchase.line')
line = Line()
line.premium_decomposition = [
Mock(base_amount=Decimal('12.50')),
Mock(base_amount=Decimal('-2.25')),
]
self.assertEqual(line.get_premium('premium'), Decimal('10.2500'))
def test_sale_line_premium_price_falls_back_to_decomposition(self):
'sale line premium price works when the function field is not loaded'
class LineWithoutPremium:
premium_decomposition = [
Mock(base_amount=Decimal('7.25')),
Mock(base_amount=Decimal('-1.00')),
]
get_premium = sale_module.SaleLine.get_premium
line = LineWithoutPremium()
self.assertEqual(
sale_module.SaleLine._get_premium_price(line),
Decimal('6.2500'))
def test_purchase_and_sale_line_premium_fields_are_readonly_totals(self):
'line premium fields are readonly totals from premium decomposition'
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
self.assertTrue(PurchaseLine.premium.readonly)
self.assertTrue(SaleLine.premium.readonly)
def test_premium_composition_base_amount_converts_on_contract_date(self):
'premium composition base amount converts to contract currency at spot date'
Premium = Pool().get('premium.composition')
premium = Premium()
source_currency = Mock(id=1)
target_currency = Mock(id=2)
premium.premium = Decimal('100')
premium.currency = source_currency
premium.line = Mock(purchase=Mock(
currency=target_currency,
purchase_date=datetime.date(2026, 5, 1)))
currency_model = Mock(
compute=Mock(return_value=Decimal('120')))
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
PoolMock.return_value.get.return_value = currency_model
amount = premium.get_base_amount('base_amount')
self.assertEqual(amount, Decimal('120.000000'))
def test_fee_rule_matches_purchase_line_conditions(self):
'fee rule matches purchase line conditions'
party = Mock(id=1)
product = Mock(id=2)
incoterm = Mock(id=3)
from_location = Mock(id=4)
to_location = Mock(id=5)
plan = Mock(id=6)
contract = Mock(
party=party,
incoterm=incoterm,
from_location=from_location,
to_location=to_location,
plan=plan,
)
line = Mock(product=product, purchase=contract, sale=None)
rule = fee_module.FeeRule()
rule.apply_on = 'purchase_line'
rule.party = party
rule.line_product = product
rule.incoterm_code = 'FOB'
incoterm.code = 'FOB'
rule.from_location = from_location
rule.to_location = to_location
rule.execution_plan = plan
self.assertTrue(rule._matches_line(line, 'purchase_line'))
def test_fee_rule_matches_counterparty_category_parent(self):
'fee rule can match a counterparty by parent category'
parent_category = Mock(id=1, parent=None)
child_category = Mock(id=2, parent=parent_category)
party = Mock(id=3, categories=[child_category])
contract = Mock(
party=party,
incoterm=None,
from_location=None,
to_location=None,
plan=None,
)
line = Mock(product=None, purchase=contract, sale=None)
rule = fee_module.FeeRule()
rule.apply_on = 'purchase_line'
rule.party = None
rule.party_category = parent_category
rule.line_product = None
rule.incoterm_code = None
rule.from_location = None
rule.to_location = None
rule.execution_plan = None
self.assertTrue(rule._matches_line(line, 'purchase_line'))
def test_fee_rule_builds_budgeted_sale_fee_values(self):
'fee rule builds budgeted fee values for a sale line'
currency = Mock(id=1)
unit = Mock(id=2)
party = Mock(id=3)
product = Mock(id=4)
fee_product = Mock(id=5)
sale = Mock(party=party, currency=currency)
line = Mock(
id=10,
product=product,
sale=sale,
purchase=None,
unit=unit,
quantity_theorical=Decimal('100'),
quantity=Decimal('90'),
)
rule = fee_module.FeeRule()
rule.id = 20
rule.fee_product = fee_product
rule.supplier = None
rule.p_r = 'pay'
rule.mode = 'perqt'
rule.price = Decimal('12.50')
rule.quantity = None
rule.currency = None
rule.unit = None
rule.qt_state = None
rule.weight_type = 'brut'
values = rule._fee_values(line, 'sale_line')
self.assertEqual(values['source_rule'], 20)
self.assertTrue(values['generated_by_rule'])
self.assertEqual(values['type'], 'budgeted')
self.assertEqual(values['sale_line'], 10)
self.assertEqual(values['product'], 5)
self.assertEqual(values['supplier'], 3)
self.assertEqual(values['currency'], 1)
self.assertEqual(values['unit'], 2)
self.assertEqual(values['quantity'], Decimal('100'))
def test_sale_line_basis_price_and_progress_use_manual_summary_without_component(self):
'sale line basis values use manual summary rows even without a component'
SaleLine = Pool().get('sale.line')
summary_model = Mock()
summary_model.search.side_effect = [
[Mock(price=Decimal('150'), progress=1, price_component=None)],
[Mock(price=Decimal('150'), progress=1, price_component=None)],
]
line = SaleLine()
line.id = 1
line.price_type = 'basis'
line.price_components = []
line.enable_linked_currency = False
line.linked_currency = None
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = summary_model
self.assertEqual(line.get_basis_price(), Decimal('150.0000'))
self.assertEqual(line.get_progress('progress'), 1)
def test_purchase_line_basis_price_and_progress_use_manual_summary_without_component(self):
'purchase line basis values use manual summary rows even without a component'
PurchaseLine = Pool().get('purchase.line')
summary_model = Mock()
summary_model.search.side_effect = [
[Mock(price=Decimal('150'), progress=1, price_component=None)],
[Mock(price=Decimal('150'), progress=1, price_component=None)],
]
line = PurchaseLine()
line.id = 1
line.price_type = 'basis'
line.price_components = []
line.enable_linked_currency = False
line.linked_currency = None
with patch('trytond.modules.purchase_trade.purchase.Pool') as PoolMock:
PoolMock.return_value.get.return_value = summary_model
self.assertEqual(line.get_basis_price(), Decimal('150.0000'))
self.assertEqual(line.get_progress('progress'), 1)
def test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases(self):
'sale line write increases virtual lot and open lot.qt when contractual qty grows'
SaleLine = Pool().get('sale.line')
line = Mock(id=1, quantity_theorical=Decimal('10'))
line.quantity = Decimal('10')
line.unit = Mock()
line.fees = []
vlot = Mock(id=99, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10')
line.lots = [vlot]
lqt = Mock(lot_quantity=Decimal('10'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], []]
with patch(
'trytond.modules.purchase_trade.sale.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.sale.super'
) as super_mock, patch.object(SaleLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('12.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
SaleLine.write([line], {'quantity_theorical': Decimal('12')})
self.assertEqual(lqt.lot_quantity, Decimal('12'))
self.assertEqual(line.quantity, Decimal('12.00000'))
vlot.set_current_quantity.assert_called_once_with(
Decimal('12'), Decimal('12'), 1)
lot_model.save.assert_called()
lotqt_model.save.assert_called()
save.assert_not_called()
def test_sale_line_write_blocks_theorical_qty_decrease_when_no_open_quantity(self):
'sale line write blocks contractual qty decrease when open lot.qt is insufficient'
SaleLine = Pool().get('sale.line')
line = Mock(id=2, quantity_theorical=Decimal('10'))
line.quantity = Decimal('10')
line.fees = []
vlot = Mock(id=100, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10')
line.lots = [vlot]
lqt = Mock(lot_quantity=Decimal('1'))
matched_lqt = Mock(lot_quantity=Decimal('9'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], [matched_lqt]]
with patch(
'trytond.modules.purchase_trade.sale.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.sale.super'
) as super_mock, patch.object(SaleLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('8.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
with self.assertRaises(UserError):
SaleLine.write([line], {'quantity_theorical': Decimal('8')})
save.assert_not_called()
def test_purchase_line_write_syncs_open_lot_qt_with_physical_lots(self):
'purchase line write keeps open lot.qt net of existing physical lots'
PurchaseLine = Pool().get('purchase.line')
line = Mock(id=4, quantity_theorical=Decimal('10000'))
line.quantity = Decimal('10000')
line.unit = Mock()
line.fees = []
vlot = Mock(id=102, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10000')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('10000')
line.lots = [vlot, physical]
lqt = Mock(lot_quantity=Decimal('10000'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], []]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
for record in records:
record.quantity_theorical = values['quantity_theorical']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('20000')})
self.assertEqual(lqt.lot_quantity, Decimal('10000'))
self.assertEqual(line.quantity, Decimal('10000'))
vlot.set_current_quantity.assert_not_called()
lot_model.save.assert_not_called()
lotqt_model.save.assert_not_called()
save.assert_not_called()
def test_purchase_line_write_syncs_quantity_without_physical_lots(self):
'purchase line write keeps quantity equal to contractual qty without physical lots'
PurchaseLine = Pool().get('purchase.line')
line = Mock(id=6, quantity_theorical=Decimal('1500'))
line.quantity = Decimal('1500')
line.unit = Mock()
line.fees = []
vlot = Mock(id=104, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('1500')
line.lots = [vlot]
free_lqt = Mock(lot_quantity=Decimal('50'))
allocated_lqts = [
Mock(lot_quantity=Decimal('400')),
Mock(lot_quantity=Decimal('500')),
Mock(lot_quantity=Decimal('550')),
]
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[free_lqt], allocated_lqts]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('2000.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('2000')})
self.assertEqual(line.quantity, Decimal('2000.00000'))
self.assertEqual(free_lqt.lot_quantity, Decimal('550.00000'))
vlot.set_current_quantity.assert_called_once_with(
Decimal('2000.00000'), Decimal('2000.00000'), 1)
save.assert_not_called()
def test_purchase_line_write_syncs_virtual_fee_quantity(self):
'purchase line write updates fee quantity when only virtual lot exists'
PurchaseLine = Pool().get('purchase.line')
fee = Mock()
line = Mock(id=5, quantity_theorical=Decimal('20000'))
line.quantity = Decimal('20000')
line.unit = Mock()
line.fees = [fee]
vlot = Mock(id=103, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('20000')
line.lots = [vlot]
lqt = Mock(lot_quantity=Decimal('20000'))
lot_model = Mock()
lotqt_model = Mock()
lotqt_model.search.side_effect = [[lqt], []]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
self.assertEqual(values['quantity'], Decimal('10000.00000'))
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('10000')})
self.assertEqual(lqt.lot_quantity, Decimal('10000.00000'))
fee.sync_quantity_from_lots.assert_called_once_with()
fee.adjust_purchase_values.assert_called_once_with()
save.assert_not_called()
def test_purchase_line_write_initial_theorical_qty_does_not_double_open_lot(self):
'purchase line write does not re-add quantity when initializing contractual qty'
PurchaseLine = Pool().get('purchase.line')
line = Mock(id=3, quantity_theorical=None)
line.quantity = Decimal('10')
vlot = Mock(id=101, lot_type='virtual')
vlot.get_current_quantity_converted.return_value = Decimal('10')
line.lots = [vlot]
lot_model = Mock()
lotqt_model = Mock()
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock, patch(
'trytond.modules.purchase_trade.purchase.super'
) as super_mock, patch.object(PurchaseLine, 'save') as save:
PoolMock.return_value.get.side_effect = lambda name: {
'lot.lot': lot_model,
'lot.qt': lotqt_model,
}[name]
def fake_super_write(*args):
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
for record in records:
record.quantity_theorical = values['quantity_theorical']
record.quantity = values['quantity']
super_mock.return_value.write.side_effect = fake_super_write
PurchaseLine.write(
[line], {'quantity_theorical': Decimal('10')})
vlot.set_current_quantity.assert_not_called()
lot_model.save.assert_not_called()
lotqt_model.save.assert_not_called()
save.assert_not_called()
def test_party_execution_achieved_percent_uses_real_area_statistics(self):
'party execution achieved percent reflects the controller share in its area'
PartyExecution = Pool().get('party.execution')
execution = PartyExecution()
execution.party = Mock(id=1)
execution.area = Mock(id=10)
shipments = [
Mock(controller=Mock(id=1)),
Mock(controller=Mock(id=2)),
Mock(controller=Mock(id=1)),
Mock(controller=Mock(id=2)),
Mock(controller=Mock(id=1)),
]
shipment_model = Mock()
shipment_model.search.return_value = shipments
with patch(
'trytond.modules.purchase_trade.party.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = shipment_model
self.assertEqual(
execution.get_percent('achieved_percent'),
Decimal('60.00'))
def test_get_controller_prioritizes_controller_farthest_from_target(self):
'shipment controller selection prioritizes the most under-target rule'
Shipment = Pool().get('stock.shipment.in')
Party = Pool().get('party.party')
PartyExecution = Pool().get('party.execution')
shipment = Shipment()
shipment.to_location = Mock(
country=Mock(region=Mock(id=20, parent=Mock(id=10, parent=None))))
party_a = Party()
party_a.id = 1
rule_a = PartyExecution()
rule_a.party = party_a
rule_a.area = Mock(id=10)
rule_a.percent = Decimal('80')
rule_a.compute_achieved_percent = Mock(return_value=Decimal('40'))
party_a.execution = [rule_a]
party_b = Party()
party_b.id = 2
rule_b = PartyExecution()
rule_b.party = party_b
rule_b.area = Mock(id=10)
rule_b.percent = Decimal('50')
rule_b.compute_achieved_percent = Mock(return_value=Decimal('45'))
party_b.execution = [rule_b]
category_model = Mock()
category_model.search.return_value = [Mock(id=99)]
party_category_model = Mock()
party_category_model.search.return_value = [
Mock(party=party_b),
Mock(party=party_a),
]
with patch(
'trytond.modules.purchase_trade.stock.Pool'
) as PoolMock:
def get_model(name):
return {
'party.category': category_model,
'party.party-party.category': party_category_model,
}[name]
PoolMock.return_value.get.side_effect = get_model
self.assertIs(shipment.get_controller(), party_a)
def test_weight_report_get_source_shipment_rejects_multiple_shipments(self):
'weight report export must not guess when the same WR is linked twice'
WeightReport = Pool().get('weight.report')
report = WeightReport()
report.id = 7
shipment_wr_model = Mock()
shipment_wr_model.search.return_value = [
Mock(shipment_in=Mock(id=1)),
Mock(shipment_in=Mock(id=2)),
]
with patch(
'trytond.modules.purchase_trade.weight_report.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = shipment_wr_model
with self.assertRaises(UserError):
report.get_source_shipment()
def test_weight_report_remote_context_requires_controller_and_returned_id(self):
'weight report export checks the shipment prerequisites before calling FastAPI'
WeightReport = Pool().get('weight.report')
report = WeightReport()
report.bales = 100
report.report_date = Mock(strftime=Mock(return_value='20260406'))
report.weight_date = Mock(strftime=Mock(return_value='20260406'))
shipment = Mock(
controller=None,
returned_id='RET-001',
agent=Mock(),
to_location=Mock(),
)
with self.assertRaises(UserError):
report.validate_remote_weight_report_context(shipment)
shipment.controller = Mock()
shipment.returned_id = None
with self.assertRaises(UserError):
report.validate_remote_weight_report_context(shipment)
def test_weight_report_create_from_json_accepts_missing_weight_date(self):
'weight report import keeps working when OCR returns null for weight_date'
WeightReport = Pool().get('weight.report')
party_model = Mock()
vessel_model = Mock()
location_model = Mock()
party_model.getPartyByName.side_effect = lambda name: Mock(id={
'SELLER': 1,
'BUYER': 2,
'CARRIER': 3,
}[name])
vessel_model.search.return_value = []
location_model.search.return_value = []
payload = {
'lab': 'LAB',
'report': {
'reference': 'REF',
'file_no': 'FILE',
'date': '28 October 2025',
},
'contract': {},
'parties': {
'seller': 'SELLER',
'buyer': 'BUYER',
'carrier': 'CARRIER',
},
'shipment': {
'bl_no': 'BL-1',
'weighing_place': 'PORT',
'weighing_method': 'METHOD',
'bales': 10,
},
'weights': {
'weight_date': None,
'gross_landed_kg': '10',
'tare_kg': '1',
'net_landed_kg': '9',
'invoice_net_kg': '9',
'gain_loss_kg': '0',
'gain_loss_percent': '0',
}
}
with patch(
'trytond.modules.purchase_trade.weight_report.Pool') as PoolMock, patch.object(
WeightReport, 'create', return_value=[payload]) as create_mock:
PoolMock.return_value.get.side_effect = lambda name: {
'party.party': party_model,
'trade.vessel': vessel_model,
'stock.location': location_model,
}[name]
created = WeightReport.create_from_json(payload)
self.assertEqual(created, payload)
values = create_mock.call_args.args[0][0]
self.assertIsNone(values['weight_date'])
self.assertEqual(values['report_date'].isoformat(), '2025-10-28')
def test_weight_report_create_from_json_accepts_bales_with_thousand_separator(self):
'weight report import accepts OCR bale counts formatted like 1.100'
WeightReport = Pool().get('weight.report')
party_model = Mock()
vessel_model = Mock()
location_model = Mock()
party_model.getPartyByName.side_effect = lambda name: Mock(id={
'SELLER': 1,
'BUYER': 2,
'CARRIER': 3,
}[name])
vessel_model.search.return_value = []
location_model.search.return_value = []
payload = {
'lab': 'LAB',
'report': {
'reference': 'REF',
'file_no': 'FILE',
'date': '02 Apr 2026',
},
'contract': {},
'parties': {
'seller': 'SELLER',
'buyer': 'BUYER',
'carrier': 'CARRIER',
},
'shipment': {
'bl_no': 'BL-1',
'weighing_place': 'PORT',
'weighing_method': 'METHOD',
'bales': '1.100',
},
'weights': {
'weight_date': None,
'gross_landed_kg': '10',
'tare_kg': '1',
'net_landed_kg': '9',
'invoice_net_kg': '9',
'gain_loss_kg': '0',
'gain_loss_percent': '0',
}
}
with patch(
'trytond.modules.purchase_trade.weight_report.Pool') as PoolMock, patch.object(
WeightReport, 'create', return_value=[payload]) as create_mock:
PoolMock.return_value.get.side_effect = lambda name: {
'party.party': party_model,
'trade.vessel': vessel_model,
'stock.location': location_model,
}[name]
created = WeightReport.create_from_json(payload)
self.assertEqual(created, payload)
values = create_mock.call_args.args[0][0]
self.assertEqual(values['bales'], 1100)
def test_invoice_report_uses_invoice_template_from_configuration(self):
'invoice report path is resolved from purchase_trade configuration'
report_class = Pool().get('account.invoice', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
sale_report_template='sale_melya.fodt',
sale_commission_report_template='sale/sale_commission_ict.fodt',
sale_bill_report_template='bill_melya.fodt',
sale_final_report_template='sale_final_melya.fodt',
invoice_report_template='invoice_melya.fodt',
invoice_cndn_report_template='invoice_ict_final.fodt',
invoice_commission_cndn_report_template=(
'commission__ict_final.fodt'),
invoice_prepayment_report_template='prepayment.fodt',
invoice_packing_list_report_template='packing_list.fodt',
invoice_payment_order_report_template='payment_order.fodt',
purchase_report_template='purchase_melya.fodt',
purchase_commission_report_template=(
'purchase/purchase_commission_ict.fodt'),
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Invoice',
'report': 'account_invoice/invoice.fodt',
}),
'account_invoice/invoice_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Prepayment',
'report': 'account_invoice/prepayment.fodt',
}),
'account_invoice/prepayment.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'CN/DN',
'report': 'account_invoice/invoice_ict_final.fodt',
}),
'account_invoice/invoice_ict_final.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'CN/DN Commission',
'report': 'account_invoice/commission__ict_final.fodt',
}),
'account_invoice/commission__ict_final.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Commission invoice Sale',
'report': 'account_invoice/sale_commission_ict.fodt',
}),
'account_invoice/sale_commission_ict.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Commission invoice Purchase',
'report': 'account_invoice/purchase_commission_ict.fodt',
}),
'account_invoice/purchase_commission_ict.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Payment Order',
'report': 'account_invoice/payment_order.fodt',
}),
'account_invoice/payment_order.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Packing List',
'report': 'account_invoice/packing_list.fodt',
}),
'account_invoice/packing_list.fodt')
def test_invoice_report_raises_when_template_is_missing(self):
'invoice report must fail clearly when no template is configured'
report_class = Pool().get('account.invoice', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
invoice_report_template='',
invoice_cndn_report_template='',
invoice_commission_cndn_report_template='',
sale_commission_report_template='',
purchase_commission_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': 'Commission invoice Sale',
'report': 'account_invoice/sale_commission_ict.fodt',
})
with self.assertRaises(UserError):
report_class._resolve_configured_report_path({
'name': 'Commission invoice Purchase',
'report': 'account_invoice/purchase_commission_ict.fodt',
})
with self.assertRaises(UserError):
report_class._resolve_configured_report_path({
'name': 'Payment Order',
'report': 'account_invoice/payment_order.fodt',
})
with self.assertRaises(UserError):
report_class._resolve_configured_report_path({
'name': 'Packing List',
'report': 'account_invoice/packing_list.fodt',
})
def test_sale_report_uses_templates_from_configuration(self):
'sale report paths are resolved from purchase_trade configuration'
report_class = Pool().get('sale.sale', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
sale_report_template='sale_melya.fodt',
sale_commission_report_template='sale_commission_ict.fodt',
sale_bill_report_template='bill_melya.fodt',
sale_final_report_template='sale_final_melya.fodt',
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Sale',
'report': 'sale/sale.fodt',
}),
'sale/sale_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Bill',
'report': 'sale/bill.fodt',
}),
'sale/bill_melya.fodt')
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Sale (final)',
'report': 'sale/sale_final.fodt',
}),
'sale/sale_final_melya.fodt')
def test_purchase_report_uses_template_from_configuration(self):
'purchase report path is resolved from purchase_trade configuration'
report_class = Pool().get('purchase.purchase', type='report')
config_model = Mock()
config_model.search.return_value = [
Mock(
purchase_report_template='purchase_melya.fodt',
purchase_commission_report_template=(
'purchase_commission_ict.fodt'),
)
]
with patch(
'trytond.modules.purchase_trade.invoice.Pool'
) as PoolMock:
PoolMock.return_value.get.return_value = config_model
self.assertEqual(
report_class._resolve_configured_report_path({
'name': 'Purchase',
'report': 'purchase/purchase.fodt',
}),
'purchase/purchase_melya.fodt')
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')
linkage_report = Pool().get('stock.shipment.in.linkage', 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',
shipment_linkage_report_template='linkage_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')
self.assertEqual(
linkage_report._resolve_configured_report_path({
'name': 'Linkage',
'report': 'stock/linkage.fodt',
}),
'stock/linkage_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='',
shipment_linkage_report_label='Linkage Summary',
)
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'
action_linkage = Mock(spec=['name'])
action_linkage.name = 'Linkage'
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,
13: action_linkage,
}
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'},
[actions[13]], {'name': 'Linkage Summary'},
))
def test_shipment_insurance_helpers_use_fee_and_controller(self):
'shipment insurance helpers read insurance fee and shipment context'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.number = 'IN/0001'
shipment.bl_number = 'BL-001'
shipment.from_location = Mock(name='LIVERPOOL')
shipment.to_location = Mock(name='LE HAVRE')
shipment.vessel = Mock(vessel_name='MV ATLANTIC')
shipment.controller = Mock(rec_name='CONTROL UNION')
shipment.supplier = Mock(rec_name='MELYA SA')
sale_party = Mock(rec_name='SGT FR')
sale = Mock(
id=1,
party=sale_party,
report_melya_proforma_number='P-12 S-34',
)
product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON')
line = Mock(product=product, sale=sale)
invoice = Mock(number='INV-001')
snapshot_unit = Mock(symbol='mt', rec_name='MT')
invoice_line = Mock(
invoice=invoice,
lot_weight_snapshots=[
Mock(net_quantity=Decimal('24.0000'), unit=snapshot_unit),
],
)
lot = Mock(
id=1,
lot_type='physic',
sale_line=line,
line=None,
sale_invoice_line=invoice_line,
sale_invoice_line_prov=None,
invoice_line=None,
invoice_line_prov=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',
party_name='MELYA INTERNATIONAL TRADING SA')),
)
)
self.assertEqual(
shipment.report_insurance_certificate_number, 'BL-001')
self.assertEqual(
shipment.report_insurance_account_of,
'MELYA INTERNATIONAL TRADING SA')
self.assertEqual(
shipment.report_insurance_goods_description,
'24mt COTTON UPLAND - RAW WHITE COTTON')
self.assertEqual(
shipment.report_insurance_order_reference, 'P-12 S-34')
self.assertEqual(
shipment.report_insurance_invoice_number, 'INV-001')
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_linkage_helpers_use_matched_purchase_sale_lot(self):
'linkage helpers expose matched purchase and sale values'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.bl_date = datetime.date(2026, 4, 30)
freight_product = SimpleNamespace(
rec_name='[Maritime Freight] Maritime freight',
name='[Maritime Freight] Maritime freight')
shipment.fees = [
SimpleNamespace(
id=201, product=freight_product, price=Decimal('10'),
quantity=Decimal('1'), mode='lumpsum', p_r='pay'),
SimpleNamespace(
id=202, product=freight_product, price=Decimal('15'),
quantity=Decimal('1'), mode='lumpsum', p_r='pay'),
]
shipment.moves = []
shipment.lotqt = []
currency = SimpleNamespace(code='USD')
unit = SimpleNamespace(symbol='MT')
product = SimpleNamespace(
rec_name='Sulphuric Acid',
name='Sulphuric Acid',
code='H2SO4')
purchase = SimpleNamespace(
id=1,
number='P-10',
reference='26.0001',
currency=currency,
party=SimpleNamespace(rec_name='SUPPLIER SA'),
to_location=SimpleNamespace(rec_name='Odda'),
incoterm=SimpleNamespace(code='FOB'),
operator=SimpleNamespace(rec_name='Operator A'))
sale = SimpleNamespace(
id=2,
number='S-20',
reference='26.0001',
currency=currency,
party=SimpleNamespace(rec_name='CLIENT SA'),
to_location=SimpleNamespace(rec_name='Mejillones'),
incoterm=SimpleNamespace(code='CFR'))
period = SimpleNamespace(rec_name='Chile FY26')
purchase_line = SimpleNamespace(
id=11,
product=product,
purchase=purchase,
quantity_theorical=Decimal('100'),
quantity=Decimal('100'),
unit_price=Decimal('80'),
unit=unit,
price_type='fixed',
del_period=period,
period_at='laycan',
from_del=datetime.date(2026, 4, 30),
to_del=datetime.date(2026, 5, 8),
fees=[],
mtm=[])
sale_line = SimpleNamespace(
id=12,
product=product,
sale=sale,
quantity_theorical=Decimal('100'),
quantity=Decimal('100'),
unit_price=Decimal('100'),
unit=unit,
price_type='fixed',
del_period=period,
period_at='laycan',
from_del=datetime.date(2026, 4, 30),
to_del=datetime.date(2026, 4, 30),
fees=[],
mtm=[])
lot = SimpleNamespace(
id=101,
lot_type='physic',
line=purchase_line,
sale_line=sale_line)
shipment.incoming_moves = [SimpleNamespace(lot=lot)]
self.assertEqual(
shipment.report_linkage_title, 'Linkage P-10/S-20 26.0001')
self.assertEqual(shipment.report_linkage_desk, 'Sulfuric Acid')
self.assertEqual(shipment.report_linkage_book, 'H2SO4 Chile FY26')
self.assertEqual(
shipment.report_linkage_bl_date, 'Thursday, April 30, 2026')
self.assertIn('Purchases', shipment.report_linkage_summary_groups)
self.assertIn('Sales', shipment.report_linkage_summary_groups)
self.assertIn('-8,000.000', shipment.report_linkage_summary_estimated)
self.assertIn('10,000.000', shipment.report_linkage_summary_estimated)
summary_rows = shipment._get_report_linkage_summary_rows()
freight_rows = [
row for row in summary_rows
if row[0] == 'Costs' and row[1] == 'Maritime freight']
self.assertEqual(len(freight_rows), 1)
self.assertEqual(freight_rows[0][2], Decimal('-25'))
self.assertIn('P-10', shipment.report_linkage_movement_references)
self.assertIn('S-20', shipment.report_linkage_movement_references)
self.assertIn('100 MT', shipment.report_linkage_movement_quantities)
self.assertIn('80.000 USD/MT', shipment.report_linkage_pricing_input_prices)
self.assertIn('100.000 USD/MT', shipment.report_linkage_pricing_input_prices)
def test_linkage_report_does_not_extend_lotqt_model(self):
'linkage report must not alter the lot.qt ORM model'
self.assertFalse(hasattr(stock_module, 'LotQt'))
def test_linkage_report_final_validates_ordered_matching_fee(self):
'linkage final keeps budgeted estimate and validates matching ordered fee'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.bl_date = datetime.date(2026, 4, 30)
shipment.fees = []
shipment.moves = []
shipment.lotqt = []
currency = SimpleNamespace(id=1, rec_name='USD')
unit = SimpleNamespace(symbol='MT')
product = SimpleNamespace(id=1, rec_name='Sulphuric Acid')
purchase = SimpleNamespace(
id=10,
number='P-10',
reference='26.0001',
currency=currency,
party=SimpleNamespace(id=20, rec_name='SUPPLIER SA'),
to_location=SimpleNamespace(rec_name='Odda'),
incoterm=SimpleNamespace(code='FOB'))
purchase_line = SimpleNamespace(
id=11,
product=product,
purchase=purchase,
quantity_theorical=Decimal('100'),
quantity=Decimal('100'),
unit_price=Decimal('80'),
unit=unit,
fees=[],
mtm=[])
fee_product = SimpleNamespace(id=30, name='Maritime freight')
supplier = SimpleNamespace(id=40, rec_name='FREIGHT CO')
budgeted_fee = SimpleNamespace(
id=201,
type='budgeted',
line=purchase_line,
sale_line=None,
product=fee_product,
supplier=supplier,
price=Decimal('100'),
quantity=Decimal('1'),
mode='lumpsum',
p_r='pay')
ordered_fee = SimpleNamespace(
id=202,
type='ordered',
line=purchase_line,
sale_line=None,
product=fee_product,
supplier=supplier,
price=Decimal('125'),
quantity=Decimal('1'),
mode='lumpsum',
p_r='pay')
other_ordered_fee = SimpleNamespace(
id=203,
type='ordered',
line=purchase_line,
sale_line=None,
product=fee_product,
supplier=supplier,
price=Decimal('999'),
quantity=Decimal('1'),
mode='lumpsum',
p_r='pay')
purchase_line.fees = [budgeted_fee, ordered_fee, other_ordered_fee]
lot = SimpleNamespace(
id=101,
lot_type='physic',
line=purchase_line,
sale_line=None)
other_lot = SimpleNamespace(
id=102,
lot_type='physic',
line=purchase_line,
sale_line=None)
budgeted_fee.lots = [lot]
ordered_fee.lots = [lot]
other_ordered_fee.lots = [other_lot]
shipment.incoming_moves = [SimpleNamespace(lot=lot)]
provisional_fee_row = [
row for row in shipment._get_report_linkage_summary_rows()
if row[0] == 'Costs' and row[1] == 'Maritime freight'][0]
self.assertEqual(provisional_fee_row[2], Decimal('-100'))
self.assertEqual(provisional_fee_row[3], Decimal('-125'))
shipment.report_linkage_status = 'FINAL'
final_fee_row = [
row for row in shipment._get_report_linkage_summary_rows()
if row[0] == 'Costs' and row[1] == 'Maritime freight'][0]
self.assertEqual(final_fee_row[2], Decimal('-100'))
self.assertEqual(final_fee_row[3], Decimal('-125'))
def test_lot_report_linkage_rejects_non_physical_lines(self):
'lot.report linkage report is only available for physical lines'
linkage_report = Pool().get('lot.report.linkage', type='report')
lot_report_model = Mock()
lot_report_model.browse.return_value = [
SimpleNamespace(
id=10000001,
rec_name='Open quantity',
r_lot_type='virtual',
r_lot_p=None,
r_lot_s=None,
)
]
with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock:
PoolMock.return_value.get.return_value = lot_report_model
with self.assertRaises(UserError):
linkage_report._get_records([10000001], 'lot.report', {})
def test_lot_report_linkage_helpers_use_physical_lot(self):
'lot.report linkage report exposes data from the selected physical lot'
currency = SimpleNamespace(code='USD')
unit = SimpleNamespace(symbol='MT')
product = SimpleNamespace(
rec_name='Sulphuric Acid',
name='Sulphuric Acid',
code='H2SO4')
purchase = SimpleNamespace(
id=1,
number='P-10',
reference='26.0001',
currency=currency,
party=SimpleNamespace(rec_name='SUPPLIER SA'),
company=SimpleNamespace(party=SimpleNamespace(rec_name='TRADING SA')),
to_location=SimpleNamespace(rec_name='Odda'),
incoterm=SimpleNamespace(code='FOB'),
operator=SimpleNamespace(rec_name='Operator A'))
sale = SimpleNamespace(
id=2,
number='S-20',
reference='26.0001',
currency=currency,
party=SimpleNamespace(rec_name='CLIENT SA'),
to_location=SimpleNamespace(rec_name='Mejillones'),
incoterm=SimpleNamespace(code='CFR'))
period = SimpleNamespace(rec_name='Chile FY26')
purchase_line = SimpleNamespace(
id=11,
product=product,
purchase=purchase,
quantity_theorical=Decimal('100'),
quantity=Decimal('100'),
unit_price=Decimal('80'),
unit=unit,
price_type='fixed',
del_period=period,
period_at='laycan',
from_del=datetime.date(2026, 4, 30),
to_del=datetime.date(2026, 5, 8),
fees=[],
mtm=[])
sale_line = SimpleNamespace(
id=12,
product=product,
sale=sale,
quantity_theorical=Decimal('100'),
quantity=Decimal('100'),
unit_price=Decimal('100'),
unit=unit,
price_type='fixed',
del_period=period,
period_at='laycan',
from_del=datetime.date(2026, 4, 30),
to_del=datetime.date(2026, 4, 30),
fees=[],
mtm=[])
physical_lot = SimpleNamespace(
id=101,
rec_name='LOT-101',
line=purchase_line,
sale_line=sale_line,
lot_shipment_in=SimpleNamespace(
bl_date=datetime.date(2026, 4, 30),
fees=[]),
move=None)
lot_report = SimpleNamespace(
id=101,
rec_name='Physical lot',
r_lot_type='physic',
r_lot_p=physical_lot,
r_lot_s=None)
record = stock_module.LotReportLinkageRecord(
physical_lot, lot_report)
self.assertEqual(
record.report_linkage_title,
'Linkage P-10/S-20 26.0001 - PROVISIONAL')
final_record = stock_module.LotReportLinkageRecord(
physical_lot, lot_report, linkage_status='FINAL')
self.assertEqual(
final_record.report_linkage_title,
'Linkage P-10/S-20 26.0001 - FINAL')
self.assertEqual(record.report_linkage_bl_date, 'Thursday, April 30, 2026')
self.assertIn('Purchases', record.report_linkage_summary_groups)
self.assertIn('Sales', record.report_linkage_summary_groups)
self.assertIn('P-10', record.report_linkage_movement_references)
self.assertIn('S-20', record.report_linkage_movement_references)
self.assertIn('80.000 USD/MT', record.report_linkage_pricing_input_prices)
self.assertIn('100.000 USD/MT', record.report_linkage_pricing_input_prices)
def test_shipment_insurance_contact_surveyor_prefers_shipment_surveyor(self):
'insurance contact surveyor property uses shipment surveyor first'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.surveyor = Mock(rec_name='SGS')
shipment.controller = Mock(rec_name='CONTROL UNION')
shipment.fees = []
self.assertEqual(
shipment.report_insurance_contact_surveyor, 'SGS')
self.assertEqual(
shipment.report_insurance_surveyor, 'SGS')
def test_shipment_insurance_order_reference_falls_back_to_lot_qt(self):
'insurance order reference can read sale deal through matching lot.qt'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
sale = Mock(
id=1,
report_melya_proforma_number='',
report_deal='P-12 S-34',
)
sale_lot = Mock(sale_line=Mock(sale=sale))
shipment.incoming_moves = []
shipment.moves = []
shipment.lotqt = [Mock(lot_s=sale_lot)]
self.assertEqual(
shipment.report_insurance_order_reference, 'P-12 S-34')
def test_shipment_packing_helpers_use_today_and_trade_unit(self):
'packing list helpers expose today date and trade line unit'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.quantity = Decimal('1010')
shipment.unit = Mock(symbol='KGS', rec_name='KGS')
trade_line = Mock()
trade_line.unit = Mock(symbol='MT', rec_name='MT')
lot = Mock(line=trade_line, sale_line=None)
lot.get_current_quantity = Mock(return_value=Decimal('1010'))
lot.get_current_gross_quantity = Mock(return_value=Decimal('1012'))
move = Mock(lot=lot, quantity=Decimal('0'))
shipment.incoming_moves = [move]
shipment.moves = [move]
with patch(
'trytond.modules.purchase_trade.stock.Pool'
) as PoolMock:
date_model = Mock()
date_model.today.return_value = datetime.date(2026, 4, 8)
PoolMock.return_value.get.return_value = date_model
self.assertEqual(
shipment.report_packing_today_date, 'April 8, 2026')
self.assertEqual(
shipment.report_packing_weight_unit, 'MT')
def test_sale_report_multi_line_helpers_aggregate_all_lines(self):
'sale report helpers aggregate quantity, price lines and shipment periods'
Sale = Pool().get('sale.sale')
def make_line(quantity, period, linked_price):
line = Mock()
line.type = 'line'
line.quantity = quantity
line.note = ''
line.price_type = 'priced'
line.unit_price = Decimal('0')
line.linked_price = Decimal(linked_price)
line.linked_currency = Mock(rec_name='USC')
line.linked_unit = Mock(rec_name='POUND')
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description=period)
line.get_pricing_text = f'ON ICE Cotton #2 {period}'
line.lots = []
return line
sale = Sale()
sale.currency = Mock(rec_name='USD')
sale.lines = [
make_line('1000', 'MARCH 2026', '72.5000'),
make_line('1000', 'MAY 2026', '70.2500'),
]
self.assertEqual(sale.report_total_quantity, '2000.0')
self.assertEqual(sale.report_quantity_unit_upper, 'MT')
self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS')
self.assertEqual(sale.report_nb_bale, '')
self.assertEqual(
sale.report_quantity_lines.splitlines(),
[
'1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026',
'1000.0 MT (ONE THOUSAND METRIC TONS) - MAY 2026',
])
self.assertEqual(
sale.report_shipment_periods.splitlines(),
['MARCH 2026', 'MAY 2026'])
self.assertEqual(
sale.report_price_lines.splitlines(),
[
'USC 72.5000 PER POUND (SEVENTY TWO USC AND FIFTY CENTS) ON ICE Cotton #2 MARCH 2026',
'USC 70.2500 PER POUND (SEVENTY USC AND TWENTY FIVE CENTS) ON ICE Cotton #2 MAY 2026',
])
def test_sale_report_price_composition_lines_uses_composition(self):
'sale report exposes price composition lines for invoice_melya'
Sale = Pool().get('sale.sale')
line = Mock()
line.type = 'line'
line.price_type = 'priced'
line.linked_currency = None
line.linked_unit = None
line.unit = Mock(rec_name='MT')
line.quantity = Decimal('25')
line.unit_price = Decimal('2525')
line.amount = Decimal('63750')
line.lots = []
line.price_composition = [
Mock(component='FOB', price=Decimal('2500')),
Mock(component='FREIGHT', price=None),
]
sale = Sale()
sale.currency = Mock(rec_name='EUR')
sale.lines = [line]
self.assertEqual(
sale.report_price_composition_lines.splitlines(),
[
'FOB value in EUR',
'FREIGHT value in EUR',
])
self.assertEqual(
sale.report_price_composition_rows,
[
{
'label': 'FOB value in EUR',
'amount': Decimal('62500'),
'amount_text': '62,500.00',
},
{
'label': 'FREIGHT value in EUR',
'amount': Decimal('1250'),
'amount_text': '1,250.00',
},
])
def test_invoice_report_melya_price_composition_uses_invoice_quantity(self):
'invoice_melya price composition applies the invoiced quantity'
Invoice = Pool().get('account.invoice')
unit = Mock(id=1, rec_name='MT')
sale = Mock(currency=Mock(rec_name='EUR'), lines=[])
sale_line = Mock(
sale=sale,
unit=unit,
amount=Decimal('63750'),
price_composition=[
Mock(component='FOB', price=Decimal('2500')),
Mock(component='FREIGHT', price=None),
])
invoice_line = Mock(
type='line',
origin=sale_line,
unit=unit,
quantity=Decimal('20'),
amount=Decimal('51000'),
currency=Mock(rec_name='EUR'),
invoice=None,
lot=None,
lot_weight_snapshots=[])
invoice = Invoice()
invoice.lines = [invoice_line]
invoice.sales = [sale]
self.assertEqual(
invoice.report_melya_rate_rows,
[
{
'label': 'FOB value in EUR',
'amount': Decimal('50000'),
'amount_text': '50,000.00',
},
{
'label': 'FREIGHT value in EUR',
'amount': Decimal('1000'),
'amount_text': '1,000.00',
},
])
def test_invoice_report_rate_lines_prefers_sale_price_composition(self):
'invoice_melya rate block uses sale price composition when available'
Invoice = Pool().get('account.invoice')
sale = Mock(
report_price_composition_rows=[
{'label': 'FOB value in EUR', 'amount': Decimal('2500')},
{'label': 'FREIGHT value in EUR', 'amount': Decimal('25')},
])
invoice = Invoice()
invoice.sales = [sale]
self.assertEqual(
invoice.report_rate_lines.splitlines(),
[
'FOB value in EUR',
'FREIGHT value in EUR',
])
self.assertEqual(
invoice.report_rate_rows,
[
{'label': 'FOB value in EUR', 'amount': Decimal('2500')},
{'label': 'FREIGHT value in EUR', 'amount': Decimal('25')},
])
def test_invoice_report_melya_rate_rows_has_no_price_fallback(self):
'invoice_melya rate block stays empty without price composition'
Invoice = Pool().get('account.invoice')
line = Mock(
type='line',
report_rate_currency_upper='USD',
report_rate_value=Decimal('2050'),
report_rate_unit_upper='MT',
report_rate_price_words='TWO THOUSAND FIFTY DOLLARS',
report_rate_pricing_text='',
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_melya_rate_rows, [])
self.assertEqual(
invoice.report_rate_rows,
[{
'label': 'USD 2050.0000 PER MT '
'(TWO THOUSAND FIFTY DOLLARS)',
'amount': None,
}])
def test_sale_commission_invoice_uses_linked_perqt_fee_rate(self):
'sale commission invoice shows linked fee price per sale quantity'
Invoice = Pool().get('account.invoice')
sale = SimpleNamespace(
currency=SimpleNamespace(rec_name='USD'),
untaxed_amount=Decimal('7960'),
total_amount=Decimal('7960'))
sale_line = SimpleNamespace(sale=sale)
lot = SimpleNamespace(id=1, sale_line=sale_line, line=None)
fee = SimpleNamespace(
mode='perqt',
price=Decimal('0.02'),
enable_linked_currency=True,
linked_price=Decimal('2'),
linked_currency=SimpleNamespace(rec_name='USC'),
linked_unit=SimpleNamespace(rec_name='Kilogram'),
currency=SimpleNamespace(rec_name='USD'),
unit=SimpleNamespace(rec_name='MT'),
sale_line=sale_line,
line=None,
lots=[lot],
_get_effective_fee_lots=lambda: [lot])
invoice = Invoice()
invoice.currency = SimpleNamespace(rec_name='USD')
invoice.lines = [
SimpleNamespace(type='line', fee=fee, lot=lot, origin=None)]
self.assertEqual(
invoice.report_sale_commission_rate_line,
'2 USC/Kilogram')
self.assertEqual(invoice.report_sale_commission_secondary_rate_line, '')
def test_purchase_commission_invoice_uses_purchase_amount_for_percent_fee(self):
'purchase commission invoice shows percent fee on purchase amount'
Invoice = Pool().get('account.invoice')
purchase = SimpleNamespace(
currency=SimpleNamespace(rec_name='USD'),
untaxed_amount=Decimal('7960'),
total_amount=Decimal('7960'))
purchase_line = SimpleNamespace(purchase=purchase)
lot = SimpleNamespace(id=1, line=purchase_line, sale_line=None)
fee = SimpleNamespace(
mode='pprice',
price=Decimal('1.5'),
enable_linked_currency=False,
currency=SimpleNamespace(rec_name='USD'),
unit=SimpleNamespace(rec_name='MT'),
sale_line=None,
line=purchase_line,
lots=[lot],
_get_effective_fee_lots=lambda: [lot])
invoice = Invoice()
invoice.currency = SimpleNamespace(rec_name='USD')
invoice.lines = [
SimpleNamespace(type='line', fee=fee, lot=lot, origin=None)]
self.assertEqual(
invoice.report_purchase_commission_rate_line,
'1.5 % on purchase amount 7960.00 USD')
self.assertEqual(
invoice.report_purchase_commission_secondary_rate_line, '')
def test_commission_cndn_weight_adjustment_uses_fee_lot_weights(self):
'commission note adjustment uses provisional and current lot weights'
Invoice = Pool().get('account.invoice')
unit = SimpleNamespace(id=1, rec_name='MT', symbol='MT')
provisional_line = SimpleNamespace(
quantity=Decimal('400'), unit=unit)
lot = SimpleNamespace(
id=1,
sale_invoice_line_prov=provisional_line,
sale_invoice_padding=Decimal('0'),
get_current_quantity_converted=lambda state=0, unit=None: Decimal('397'))
fee = SimpleNamespace(
enable_linked_currency=False,
mode='perqt',
price=Decimal('7.85719'),
currency=SimpleNamespace(rec_name='USD'),
unit=unit,
sale_line=SimpleNamespace(sale=SimpleNamespace()),
line=None,
lots=[lot],
_get_effective_fee_lots=lambda: [lot])
invoice = Invoice()
invoice.lines = [
SimpleNamespace(type='line', fee=fee, quantity=Decimal('1'))]
self.assertEqual(
invoice.report_commission_invoice_weight_display, '400.00')
self.assertEqual(
invoice.report_commission_landed_weight_display, '397.00')
self.assertEqual(
invoice.report_commission_weight_difference_display, '3.00')
self.assertEqual(invoice.report_commission_adjustment_unit_upper, 'MT')
self.assertEqual(
invoice.report_commission_adjustment_rate_line,
'7.8572 USD / MT')
self.assertEqual(invoice.report_commission_tax_rate_line, 'VAT 0% RATE')
def test_fee_delete_detects_generated_ordered_purchase(self):
'ordered fee deletion targets only its generated service purchase'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 10
fee.type = 'ordered'
purchase = Mock(
id=20,
line_type='service',
lines=[Mock(fee_=fee)])
fee.purchase = purchase
self.assertEqual(
Fee._get_generated_purchases_to_delete([fee]),
[purchase])
def test_fee_delete_ignores_unrelated_purchase(self):
'ordered fee deletion does not delete purchases with unrelated lines'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.id = 10
fee.type = 'ordered'
purchase = Mock(
id=20,
line_type='service',
lines=[Mock(fee_=Mock(id=99))])
fee.purchase = purchase
self.assertEqual(Fee._get_generated_purchases_to_delete([fee]), [])
def test_fee_report_invoice_delegates_to_fee_invoice(self):
'fee report invoice button invoices the underlying fees'
FeeReport = Pool().get('fee.report')
report = FeeReport()
report.id = 10
fee_model = Mock()
fees = [Mock(id=10)]
fee_model.browse.return_value = fees
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
FeeReport.invoice([report])
fee_model.browse.assert_called_once_with([10])
fee_model.invoice.assert_called_once_with(fees)
def test_fee_report_lot_invoice_status_uses_effective_lots(self):
'fee report consolidates invoicing over the effective linked lots'
FeeReport = Pool().get('fee.report')
invoice = SimpleNamespace(id=1, state='posted')
invoiced = SimpleNamespace(
invoice_line=SimpleNamespace(invoice=invoice),
invoice_line_prov=None)
not_invoiced = SimpleNamespace(
invoice_line=None, invoice_line_prov=None)
fee = SimpleNamespace(
_get_effective_fee_lots=lambda: [invoiced, not_invoiced])
lots, count, invoices = FeeReport._lot_invoice_data(fee)
self.assertEqual(lots, [invoiced, not_invoiced])
self.assertEqual(count, 1)
self.assertEqual(invoices, [invoice])
def test_fee_report_payment_status_consolidates_invoice_payments(self):
'fee report marks mixed paid and unpaid invoices as partially paid'
FeeReport = Pool().get('fee.report')
paid = SimpleNamespace(
state='paid', payment_lines=[SimpleNamespace()],
amount_to_pay=Decimal('0'))
unpaid = SimpleNamespace(
state='posted', payment_lines=[],
amount_to_pay=Decimal('10'))
self.assertEqual(
FeeReport._invoice_payment_status([paid, unpaid]), 'partial')
self.assertEqual(FeeReport._invoice_payment_status([paid]), 'paid')
self.assertEqual(FeeReport._invoice_payment_status([unpaid]), 'not')
def test_fee_report_payment_status_includes_fee_dn_cn(self):
'fee payment status distinguishes invoice and DN/CN payments'
FeeReport = Pool().get('fee.report')
report = FeeReport()
report.id = 10
paid = SimpleNamespace(
id=1, state='paid', payment_lines=[SimpleNamespace()],
amount_to_pay=Decimal('0'))
unpaid_dn_cn = SimpleNamespace(
id=2, state='posted', payment_lines=[],
amount_to_pay=Decimal('5'))
fee = Mock(dn_cn=unpaid_dn_cn)
fee.get_invoice.return_value = paid
fee_model = Mock(return_value=fee)
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
self.assertEqual(
report.get_fee_payment_status('r_fee_payment_status'),
'invoice_paid')
fee.get_invoice.assert_called_once_with('inv')
unpaid_dn_cn.payment_lines = [SimpleNamespace()]
unpaid_dn_cn.amount_to_pay = Decimal('0')
with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
self.assertEqual(
report.get_fee_payment_status('r_fee_payment_status'),
'all_paid')
unpaid = SimpleNamespace(
state='posted', payment_lines=[],
amount_to_pay=Decimal('5'))
self.assertEqual(
FeeReport._fee_payment_status(unpaid, unpaid), 'not_paid')
self.assertEqual(
FeeReport._fee_payment_status(unpaid, unpaid_dn_cn),
'dn_cn_paid')
def test_sale_report_converts_mixed_units_for_total_and_words(self):
'sale report totals prefer the virtual lot unit as common unit'
Sale = Pool().get('sale.sale')
mt = Mock(id=1, rec_name='MT')
kg = Mock(id=2, rec_name='KILOGRAM')
line_mt = Mock()
line_mt.type = 'line'
line_mt.quantity = Decimal('1000')
line_mt.unit = mt
line_mt.del_period = Mock(description='MARCH 2026')
line_mt.lots = []
virtual = Mock(lot_type='virtual', lot_unit_line=kg)
virtual.get_hist_quantity.return_value = (
Decimal('1000000'),
Decimal('1000000'),
)
line_kg = Mock()
line_kg.type = 'line'
line_kg.quantity = Decimal('1000')
line_kg.unit = mt
line_kg.del_period = Mock(description='MAY 2026')
line_kg.lots = [virtual]
sale = Sale()
sale.lines = [line_mt, line_kg]
uom_model = Mock()
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: (
qty * 1000
if getattr(from_unit, 'rec_name', None) == 'MT'
and getattr(to_unit, 'rec_name', None) == 'KILOGRAM'
else (
qty / 1000
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
and getattr(to_unit, 'rec_name', None) == 'MT'
else qty
)
))
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(sale.report_total_quantity, '2000000.0')
self.assertEqual(sale.report_quantity_unit_upper, 'KILOGRAM')
self.assertEqual(sale.report_qt, 'TWO MILLION KILOGRAMS')
self.assertEqual(
sale.report_quantity_lines.splitlines(),
[
'1000.0 MT (ONE THOUSAND METRIC TONS) - MARCH 2026',
'1000000.0 KILOGRAM (ONE MILLION KILOGRAMS) - MAY 2026',
])
def test_sale_report_total_unit_falls_back_when_multiple_virtual_lots(self):
'sale report common unit uses virtual only when there is a single one'
Sale = Pool().get('sale.sale')
mt = Mock(id=1, rec_name='MT')
kg = Mock(id=2, rec_name='KILOGRAM')
line_mt = Mock(type='line', quantity=Decimal('1000'), unit=mt)
line_mt.del_period = Mock(description='MARCH 2026')
line_mt.lots = []
virtual_a = Mock(lot_type='virtual', lot_unit_line=kg)
virtual_a.get_hist_quantity.return_value = (
Decimal('1000000'),
Decimal('1000000'),
)
virtual_b = Mock(lot_type='virtual', lot_unit_line=kg)
virtual_b.get_hist_quantity.return_value = (
Decimal('1000000'),
Decimal('1000000'),
)
line_kg = Mock(type='line', quantity=Decimal('1000'), unit=mt)
line_kg.del_period = Mock(description='MAY 2026')
line_kg.lots = [virtual_a, virtual_b]
sale = Sale()
sale.lines = [line_mt, line_kg]
uom_model = Mock()
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: (
qty / 1000
if getattr(from_unit, 'rec_name', None) == 'KILOGRAM'
and getattr(to_unit, 'rec_name', None) == 'MT'
else qty
))
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(sale.report_quantity_unit_upper, 'MT')
self.assertEqual(sale.report_total_quantity, '2000.0')
self.assertEqual(sale.report_qt, 'TWO THOUSAND METRIC TONS')
def test_report_product_fields_expose_name_and_description(self):
'sale and invoice templates use stable product name/description helpers'
Sale = Pool().get('sale.sale')
Invoice = Pool().get('account.invoice')
product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON')
sale_line = Mock(product=product)
invoice_line = Mock(product=product)
sale = Sale()
sale.lines = [sale_line]
invoice = Invoice()
invoice.lines = [invoice_line]
invoice._get_report_trade_line = Mock(return_value=sale_line)
self.assertEqual(sale.report_product_name, 'COTTON UPLAND')
self.assertEqual(sale.report_product_description, 'RAW WHITE COTTON')
self.assertEqual(invoice.report_product_name, 'COTTON UPLAND')
self.assertEqual(invoice.report_product_description, 'RAW WHITE COTTON')
def test_invoice_melya_reference_uses_sale_deal_number(self):
'invoice_melya Reference uses the sale deal number'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
sale = Mock(report_deal='P-12 S-34')
invoice._get_report_deal_sale = Mock(return_value=sale)
self.assertEqual(invoice.report_contract_number, 'P-12/S-34')
def test_invoice_melya_bank_uses_sale_our_bank_account(self):
'invoice_melya bank block uses the sale company bank account'
Invoice = Pool().get('account.invoice')
address = Mock(
party_full_name='UBS SWITZERLAND AG',
name='Case Postale',
street='',
postal_code='CH-1211 Geneve 2',
city='',
full_address='UBS SWITZERLAND AG\nCase Postale\n'
'CH-1211 Geneve 2',
)
party = Mock(rec_name='UBS')
party.address_get.return_value = address
bank = Mock(party=party, bic='UBSWCHZH80A')
account = Mock(
bank=bank,
numbers=[Mock(type='iban', number='CH36 0021 5215 2487 7461 D')],
)
sale = Mock(our_bank_account=account)
invoice = Invoice()
invoice._get_report_deal_sale = Mock(return_value=sale)
self.assertEqual(invoice.report_melya_bank_name, 'UBS SWITZERLAND AG')
self.assertEqual(
invoice.report_melya_bank_address,
'Case Postale\nCH-1211 Geneve 2')
self.assertEqual(
invoice.report_melya_bank_iban,
'CH36 0021 5215 2487 7461 D')
self.assertEqual(invoice.report_melya_bank_swift, 'UBSWCHZH80A')
self.assertEqual(
invoice.report_melya_bank_details_line,
'BANK DETAILS:\t\tUBS SWITZERLAND AG')
self.assertEqual(
invoice.report_melya_bank_account_line,
'IBAN : CH36 0021 5215 2487 7461 D, Swift Code: UBSWCHZH80A')
def test_sale_melya_bank_uses_our_bank_account(self):
'sale_melya bank block uses the sale company bank account'
Sale = Pool().get('sale.sale')
address = Mock(
party_full_name='UBS SWITZERLAND AG',
name='Case Postale',
street='',
postal_code='CH-1211 Geneve 2',
city='',
)
party = Mock(
rec_name='UBS SWITZERLAND AG',
address_get=Mock(return_value=address),
)
bank = Mock(party=party, bic='UBSWCHZH80A')
account = Mock(
bank=bank,
numbers=[Mock(type='iban', number='CH36 0021 5215 2487 7461 D')],
)
sale = Sale()
sale.our_bank_account = account
self.assertEqual(sale.report_melya_bank_name, 'UBS SWITZERLAND AG')
self.assertEqual(
sale.report_melya_bank_address,
'Case Postale CH-1211 Geneve 2')
self.assertEqual(
sale.report_melya_bank_iban,
'CH36 0021 5215 2487 7461 D')
self.assertEqual(sale.report_melya_bank_swift, 'UBSWCHZH80A')
self.assertEqual(
sale.report_melya_bank_swift_line,
'Swift Code: UBSWCHZH80A')
self.assertEqual(
sale.report_melya_bank_iban_line,
'IBAN : CH36 0021 5215 2487 7461 D')
self.assertEqual(
sale.report_melya_bank_account_line,
'IBAN : CH36 0021 5215 2487 7461 D, Swift Code: UBSWCHZH80A')
def test_invoice_melya_maturity_date_uses_lines_to_pay(self):
'invoice_melya maturity date uses the earliest line to pay maturity'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice.lines_to_pay = [
Mock(maturity_date=datetime.date(2026, 9, 9)),
Mock(maturity_date=datetime.date(2026, 8, 9)),
Mock(maturity_date=None),
]
self.assertEqual(
invoice.report_melya_maturity_date,
datetime.date(2026, 8, 9))
def test_sale_report_deal_uses_purchase_and_sale_numbers(self):
'sale deal number includes the linked purchase number'
Sale = Pool().get('sale.sale')
purchase = Mock(number='12')
lot = Mock(line=Mock(purchase=purchase))
sale = Sale()
sale.number = '88'
sale.lines = [Mock(lots=[lot])]
self.assertEqual(sale.report_deal, 'P-12 S-88')
self.assertEqual(sale.report_melya_proforma_number, 'P-12 S-88')
def test_sale_report_deal_falls_back_to_lot_qt_matching(self):
'sale deal number finds purchase through lot.qt matching'
Sale = Pool().get('sale.sale')
class SaleLot:
id = 99
line = None
purchase = Mock(number='12')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_p=Mock(line=Mock(purchase=purchase)))]
sale = Sale()
sale.number = '88'
sale.lines = [Mock(lots=[SaleLot()])]
with patch('trytond.modules.purchase_trade.sale.Pool') as PoolMock:
PoolMock.return_value.get.return_value = lot_qt_model
self.assertEqual(sale.report_deal, 'P-12 S-88')
lot_qt_model.search.assert_called_once_with([
('lot_s', '=', 99),
('lot_p', '>', 0),
], limit=1)
def test_contract_factory_uses_one_line_per_selected_source(self):
'matched multi-lot contract creation keeps one generated line per source lot'
contract_detail = Mock(quantity=Decimal('2000'))
ct = Mock(matched=True)
line_a = Mock()
line_b = Mock()
sources = [
{'lot': Mock(), 'trade_line': line_a, 'quantity': Decimal('1000')},
{'lot': Mock(), 'trade_line': line_b, 'quantity': Decimal('1000')},
]
result = ContractFactory._get_line_sources(contract_detail, sources, ct)
self.assertEqual(len(result), 2)
self.assertEqual([r['quantity'] for r in result], [
Decimal('1000'), Decimal('1000')])
self.assertTrue(all(r['use_source_delivery'] for r in result))
def test_contract_factory_rejects_multi_lot_quantity_mismatch(self):
'matched multi-lot contract creation rejects totals that do not match the selection'
contract_detail = Mock(quantity=Decimal('1500'))
ct = Mock(matched=True)
sources = [
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')},
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('1000')},
]
with self.assertRaises(UserError):
ContractFactory._get_line_sources(contract_detail, sources, ct)
def test_contract_factory_rejects_total_above_selected_open_quantity(self):
'matched create contracts cannot consume more than selected open quantity'
contracts = [
Mock(quantity=Decimal('100')),
Mock(quantity=Decimal('100')),
]
ct = Mock(matched=True)
sources = [
{'lot': Mock(), 'trade_line': Mock(), 'quantity': Decimal('100')},
]
with self.assertRaises(UserError):
ContractFactory._validate_requested_quantity(contracts, sources, ct)
def test_contract_factory_uses_party_tolerance_when_line_is_empty(self):
'create contracts falls back to counterparty tolerance'
party = Mock(tol_min=Decimal('2'), tol_max=Decimal('5'))
contract_detail = Mock(party=party, tol_min=None, tol_max=None)
self.assertEqual(
ContractFactory._get_tolerance(contract_detail, 'tol_min'),
Decimal('2'))
self.assertEqual(
ContractFactory._get_tolerance(contract_detail, 'tol_max'),
Decimal('5'))
def test_contract_factory_uses_base_crop_when_line_is_empty(self):
'hidden crop falls back to the source contract crop'
crop = Mock()
contract_detail = Mock(crop=None)
base_contract = Mock(crop=crop)
self.assertEqual(
ContractFactory._get_crop(contract_detail, base_contract),
crop)
def test_contract_factory_locations_fallback_to_source_contract(self):
'create contracts keeps required locations when wizard line is empty'
source_from = Mock(type='supplier')
source_to = Mock(type='storage')
contract_detail = Mock(from_location=None, to_location=None)
base_contract = Mock(
from_location=source_from, to_location=source_to)
from_location, to_location = ContractFactory._get_locations(
contract_detail, base_contract, 'Sale')
self.assertEqual(from_location, source_to)
self.assertEqual(to_location, source_to)
def test_contract_factory_required_header_fields_fallback_to_defaults(self):
'create contracts fills required header fields outside the normal form'
contract = Mock()
party = Mock(wb=None, association=None, certif=None)
base_contract = Mock(wb=None, association=None, certif=None)
Contract = Mock()
Contract.default_wb.return_value = 1
Contract.default_association.return_value = 2
Contract.default_certif.return_value = 3
with patch('trytond.modules.purchase_trade.service.Pool') as PoolMock:
PoolMock.return_value.get.return_value = Contract
ContractFactory._apply_party_data(
contract, party, base_contract, 'Purchase')
self.assertEqual(contract.wb, 1)
self.assertEqual(contract.association, 2)
self.assertEqual(contract.certif, 3)
def test_contract_factory_incoterm_fallback_to_source_contract(self):
'create contracts keeps required incoterm when wizard line is empty'
incoterm = Mock()
contract_detail = Mock(incoterm=None)
base_contract = Mock(incoterm=incoterm)
self.assertEqual(
ContractFactory._get_contract_value(
contract_detail, base_contract, 'incoterm'),
incoterm)
def test_contract_factory_currency_unit_fallback_to_hidden_defaults(self):
'create contracts can price even when currency/unit selector is empty'
contract_detail = Mock(
currency_unit=None,
currency=Mock(id=7),
unit=Mock(id=9),
)
base_contract = Mock(currency=Mock(id=3))
self.assertEqual(
ContractFactory._get_currency_unit_parts(
contract_detail, base_contract),
['7', '9'])
def test_contract_factory_delivery_dates_fallback_to_period(self):
'hidden delivery dates are still derived from delivery period'
period = Mock(beg_date='2026-05-01', end_date='2026-05-31')
class ContractDetailMock:
del_period = period
contract_detail = ContractDetailMock()
line = Mock()
ContractFactory._apply_delivery(line, contract_detail, {})
self.assertEqual(line.from_del, '2026-05-01')
self.assertEqual(line.to_del, '2026-05-31')
def test_contract_factory_shipment_origin_accepts_partial_context(self):
'custom duplicate matched mirror can omit shipment fields'
class ContractsStartMock:
pass
self.assertIsNone(
ContractFactory._get_shipment_origin(ContractsStartMock()))
def test_custom_duplicate_resets_finished_line(self):
'custom duplicate keeps duplicated lots visible in lots management'
line = Mock(
product=Mock(),
quantity=None,
quantity_theorical=None,
unit_price=None,
price_type=None,
finished=True,
fees=[],
price_components=[],
)
record = Mock(lines=[line])
options = Mock(
quantity=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
currency=Mock(),
clear_fees=False,
clear_pricing_components=False,
)
line.id = 1
duplicate_module.TradeCustomDuplicate._apply_line_options(
record, options)
self.assertFalse(line.finished)
line.save.assert_called_once()
def test_custom_duplicate_creates_purchase_open_virtual_lot(self):
'custom duplicate creates the purchase opening lot.qt explicitly'
saved_lots = []
virtual_parts = []
class FakeLot:
def __init__(self, id=None):
self.id = id
@classmethod
def save(cls, lots):
for lot in lots:
lot.id = 42
saved_lots.append(lot)
def createVirtualPart(self, quantity, shipment, sale_lot):
virtual_parts.append((self.id, quantity, shipment, sale_lot))
class FakeLotQt:
@classmethod
def search(cls, domain):
return []
class FakeLotQtHist:
pass
class FakeLotQtType:
@classmethod
def search(cls, domain):
return [Mock()]
pool = Mock()
pool.get.side_effect = lambda name: {
'lot.lot': FakeLot,
'lot.qt': FakeLotQt,
'lot.qt.hist': FakeLotQtHist,
'lot.qt.type': FakeLotQtType,
}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=10,
lots=[],
product=Mock(type='goods'),
unit=Mock(),
quantity=Decimal('0'),
quantity_theorical=Decimal('125'),
fees=[],
purchase=Mock(),
)
with patch.object(duplicate_module, 'Pool', return_value=pool):
lot = duplicate_module.TradeCustomDuplicate._ensure_open_virtual_lot(
line)
self.assertEqual(lot.id, 42)
self.assertEqual(saved_lots[0].line, 10)
self.assertEqual(saved_lots[0].lot_quantity, Decimal('125.00000'))
self.assertEqual(virtual_parts, [
(42, Decimal('125.00000'), None, None)])
def test_custom_duplicate_creates_sale_open_virtual_lot(self):
'custom duplicate creates the sale opening lot.qt explicitly'
saved_lots = []
virtual_parts = []
class FakeLot:
def __init__(self, id=None):
self.id = id
@classmethod
def save(cls, lots):
for lot in lots:
lot.id = 43
saved_lots.append(lot)
def createVirtualPart(self, quantity, shipment, lot_s, mode):
virtual_parts.append((self.id, quantity, shipment, lot_s, mode))
class FakeLotQt:
@classmethod
def search(cls, domain):
return []
class FakeLotQtHist:
pass
class FakeLotQtType:
@classmethod
def search(cls, domain):
return []
pool = Mock()
pool.get.side_effect = lambda name: {
'lot.lot': FakeLot,
'lot.qt': FakeLotQt,
'lot.qt.hist': FakeLotQtHist,
'lot.qt.type': FakeLotQtType,
}[name]
line = SimpleNamespace(
__name__='sale.line',
id=11,
lots=[],
product=Mock(type='goods'),
unit=Mock(),
quantity=Decimal('0'),
quantity_theorical=Decimal('50'),
fees=[],
sale=Mock(),
)
with patch.object(duplicate_module, 'Pool', return_value=pool):
lot = duplicate_module.TradeCustomDuplicate._ensure_open_virtual_lot(
line)
self.assertEqual(lot.id, 43)
self.assertEqual(saved_lots[0].sale_line, 11)
self.assertEqual(saved_lots[0].lot_quantity, Decimal('50.00000'))
self.assertEqual(virtual_parts, [
(43, Decimal('50.00000'), None, 43, 'only sale')])
def test_custom_duplicate_defaults_mirror_from_matched_sale(self):
'custom duplicate can default mirror party from the matched sale'
purchase_party = SimpleNamespace(id=1)
sale_party = SimpleNamespace(id=2)
sale_currency = SimpleNamespace(id=30)
sale_payment_term = SimpleNamespace(id=31)
sale_incoterm = SimpleNamespace(id=32)
sale_unit = SimpleNamespace(id=33)
purchase_lot = SimpleNamespace(id=10)
sale = SimpleNamespace(
party=sale_party,
currency=sale_currency,
payment_term=sale_payment_term,
incoterm=sale_incoterm)
sale_line = SimpleNamespace(
id=20,
sale=sale,
unit=sale_unit,
quantity=Decimal('99'),
quantity_theorical=Decimal('99'),
unit_price=Decimal('52'),
price_type='basis')
lot_qt = SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line))
class FakeLotQt:
@classmethod
def search(cls, domain):
return [lot_qt]
pool = Mock()
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[purchase_lot],
unit=SimpleNamespace(id=3),
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
record = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=SimpleNamespace(id=4),
payment_term=SimpleNamespace(id=5),
incoterm=SimpleNamespace(id=6),
)
wizard = duplicate_module.TradeCustomDuplicate()
wizard.records = [record]
with patch.object(duplicate_module, 'Pool', return_value=pool):
values = wizard.default_start(None)
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
self.assertEqual(values['mirror_party'], sale_party.id)
self.assertEqual(values['mirror_currency'], sale_currency.id)
self.assertEqual(values['mirror_payment_term'], sale_payment_term.id)
self.assertEqual(values['mirror_incoterm'], sale_incoterm.id)
self.assertEqual(values['mirror_quantity'], Decimal('99'))
self.assertEqual(values['mirror_unit'], sale_unit.id)
self.assertEqual(values['mirror_unit_price'], Decimal('52'))
self.assertEqual(values['mirror_price_type'], 'basis')
def test_custom_duplicate_defaults_new_mirror_from_source_purchase(self):
'new mirror options default from the duplicated source contract'
purchase_party = SimpleNamespace(id=1)
purchase_currency = SimpleNamespace(id=4)
purchase_payment_term = SimpleNamespace(id=5)
purchase_incoterm = SimpleNamespace(id=6)
unit = SimpleNamespace(id=3)
class FakeLotQt:
@classmethod
def search(cls, domain):
return []
pool = Mock()
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[],
unit=unit,
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
record = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=purchase_currency,
payment_term=purchase_payment_term,
incoterm=purchase_incoterm,
btb=None,
)
wizard = duplicate_module.TradeCustomDuplicate()
wizard.records = [record]
with patch.object(duplicate_module, 'Pool', return_value=pool):
values = wizard.default_start(None)
self.assertEqual(values['mirror_mode'], 'new_mirror')
self.assertEqual(values['mirror_party'], purchase_party.id)
self.assertEqual(values['mirror_currency'], purchase_currency.id)
self.assertEqual(values['mirror_payment_term'], purchase_payment_term.id)
self.assertEqual(values['mirror_incoterm'], purchase_incoterm.id)
self.assertEqual(values['mirror_quantity'], Decimal('100'))
self.assertEqual(values['mirror_unit'], unit.id)
self.assertEqual(values['mirror_unit_price'], Decimal('25'))
self.assertEqual(values['mirror_price_type'], 'priced')
def test_custom_duplicate_starts_from_lot_report_purchase(self):
'custom duplicate can resolve a Lots Management row to its purchase'
purchase_party = SimpleNamespace(id=1)
purchase_currency = SimpleNamespace(id=4)
purchase_payment_term = SimpleNamespace(id=5)
purchase_incoterm = SimpleNamespace(id=6)
unit = SimpleNamespace(id=3)
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[],
unit=unit,
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
purchase = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=purchase_currency,
payment_term=purchase_payment_term,
incoterm=purchase_incoterm,
btb=None,
)
line.purchase = purchase
lot_report = SimpleNamespace(
__name__='lot.report',
r_purchase=purchase,
r_sale=None,
r_lot_p=None,
r_lot_s=None,
r_lot_himself=None,
)
wizard = duplicate_module.TradeCustomDuplicate()
wizard.records = [lot_report]
values = wizard.default_start(None)
self.assertEqual(values['source_model'], 'purchase.purchase')
self.assertEqual(values['source'], 'purchase.purchase,12')
self.assertFalse(values['duplicate_pair_available'])
self.assertEqual(values['mirror_mode'], 'new_mirror')
def test_custom_duplicate_lot_report_marks_pair_available(self):
'Lots Management custom duplicate enables pair mode when matched'
purchase_party = SimpleNamespace(id=1)
sale_party = SimpleNamespace(id=2)
unit = SimpleNamespace(id=3)
sale = SimpleNamespace(
party=sale_party,
currency=SimpleNamespace(id=30),
payment_term=SimpleNamespace(id=31),
incoterm=SimpleNamespace(id=32))
sale_line = SimpleNamespace(
id=20,
sale=sale,
unit=unit,
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('50'),
price_type='priced')
physical_lot = SimpleNamespace(
id=10,
lot_type='physic',
sale_line=sale_line)
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[physical_lot],
unit=unit,
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
purchase = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=SimpleNamespace(id=4),
payment_term=SimpleNamespace(id=5),
incoterm=SimpleNamespace(id=6),
)
lot_report = SimpleNamespace(
__name__='lot.report',
r_purchase=purchase,
r_sale=None,
r_lot_p=physical_lot,
r_lot_s=None,
r_lot_himself=None,
)
class FakeLotQt:
@classmethod
def search(cls, domain):
return []
pool = Mock()
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
wizard = duplicate_module.TradeCustomDuplicate()
wizard.records = [lot_report]
with patch.object(duplicate_module, 'Pool', return_value=pool):
values = wizard.default_start(None)
self.assertTrue(values['duplicate_pair_available'])
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
self.assertEqual(values['mirror_party'], sale_party.id)
def test_custom_duplicate_mirror_mode_change_refreshes_source_defaults(self):
'changing mirror mode refreshes mirror fields from the source contract'
purchase_party = SimpleNamespace(id=1)
purchase_currency = SimpleNamespace(id=4)
purchase_payment_term = SimpleNamespace(id=5)
purchase_incoterm = SimpleNamespace(id=6)
unit = SimpleNamespace(id=3)
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[],
unit=unit,
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
record = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=purchase_currency,
payment_term=purchase_payment_term,
incoterm=purchase_incoterm,
)
start = duplicate_module.TradeCustomDuplicateStart()
start.source = record
start.mirror_mode = 'new_mirror'
start.mirror_party = SimpleNamespace(id=99)
start.on_change_mirror_mode()
self.assertIs(start.mirror_party, purchase_party)
self.assertIs(start.mirror_currency, purchase_currency)
self.assertIs(start.mirror_payment_term, purchase_payment_term)
self.assertIs(start.mirror_incoterm, purchase_incoterm)
self.assertEqual(start.mirror_quantity, Decimal('100'))
self.assertIs(start.mirror_unit, unit)
self.assertEqual(start.mirror_unit_price, Decimal('25'))
self.assertEqual(start.mirror_price_type, 'priced')
def test_custom_duplicate_defaults_mirror_from_back_to_back_sale(self):
'custom duplicate can use the back-to-back sale as matched pair'
purchase_party = SimpleNamespace(id=1)
sale_party = SimpleNamespace(id=2)
sale_line = SimpleNamespace(
__name__='sale.line',
id=20,
type='line',
product=Mock())
sale = SimpleNamespace(
__name__='sale.sale',
id=21,
lines=[sale_line],
party=sale_party)
sale_line.sale = sale
btb = SimpleNamespace(sale=[sale], purchase=[])
class FakeLotQt:
@classmethod
def search(cls, domain):
return []
pool = Mock()
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[],
unit=SimpleNamespace(id=3),
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
record = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=SimpleNamespace(id=4),
payment_term=SimpleNamespace(id=5),
incoterm=SimpleNamespace(id=6),
btb=btb,
)
wizard = duplicate_module.TradeCustomDuplicate()
wizard.records = [record]
with patch.object(duplicate_module, 'Pool', return_value=pool):
values = wizard.default_start(None)
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
self.assertEqual(values['mirror_party'], sale_party.id)
def test_custom_duplicate_defaults_mirror_from_physical_lot_sale(self):
'custom duplicate can use the sale_line of a physical lot'
purchase_party = SimpleNamespace(id=1)
sale_party = SimpleNamespace(id=2)
sale = SimpleNamespace(party=sale_party)
sale_line = SimpleNamespace(id=20, sale=sale)
physical_lot = SimpleNamespace(
id=10,
lot_type='physic',
sale_line=sale_line)
class FakeLotQt:
@classmethod
def search(cls, domain):
return []
pool = Mock()
pool.get.side_effect = lambda name: {'lot.qt': FakeLotQt}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=11,
type='line',
product=Mock(),
lots=[physical_lot],
unit=SimpleNamespace(id=3),
quantity=Decimal('100'),
quantity_theorical=Decimal('100'),
unit_price=Decimal('25'),
price_type='priced',
)
record = SimpleNamespace(
__name__='purchase.purchase',
id=12,
lines=[line],
party=purchase_party,
currency=SimpleNamespace(id=4),
payment_term=SimpleNamespace(id=5),
incoterm=SimpleNamespace(id=6),
)
wizard = duplicate_module.TradeCustomDuplicate()
wizard.records = [record]
with patch.object(duplicate_module, 'Pool', return_value=pool):
values = wizard.default_start(None)
self.assertEqual(values['mirror_mode'], 'duplicate_pair')
self.assertEqual(values['mirror_party'], sale_party.id)
def test_custom_duplicate_warns_and_uses_first_counterpart(self):
'custom duplicate warns when several matched counterparts exist'
sale_line_a = SimpleNamespace(id=20, sale=SimpleNamespace(id=30))
sale_line_b = SimpleNamespace(id=21, sale=SimpleNamespace(id=31))
lot_qts = [
SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line_a)),
SimpleNamespace(lot_s=SimpleNamespace(sale_line=sale_line_b)),
]
class FakeLotQt:
@classmethod
def search(cls, domain):
return lot_qts
class FakeWarning:
@classmethod
def check(cls, name):
return True
pool = Mock()
pool.get.side_effect = lambda name: {
'lot.qt': FakeLotQt,
'res.user.warning': FakeWarning,
}[name]
line = SimpleNamespace(
__name__='purchase.line',
id=11,
lots=[SimpleNamespace(id=10)],
)
with patch.object(duplicate_module, 'Pool', return_value=pool):
with self.assertRaises(UserWarning):
duplicate_module.TradeCustomDuplicate._matched_counterpart_line(
line, strict=False, warn=True)
class AcknowledgedWarning:
@classmethod
def check(cls, name):
return False
pool.get.side_effect = lambda name: {
'lot.qt': FakeLotQt,
'res.user.warning': AcknowledgedWarning,
}[name]
with patch.object(duplicate_module, 'Pool', return_value=pool):
matched_line = (
duplicate_module.TradeCustomDuplicate._matched_counterpart_line(
line, strict=False, warn=True))
self.assertIs(matched_line, sale_line_a)
def test_sale_line_validate_uses_pool_fee_lots_model(self):
'sale line validation uses the real fee.lots model for new lot fees'
created_fee_lots = []
saved_lots = []
class FakeFeeLots:
@classmethod
def search(cls, domain):
return []
@classmethod
def save(cls, records):
created_fee_lots.extend(records)
class FakeLot:
def __init__(self):
self.id = 42
@classmethod
def save(cls, records):
saved_lots.extend(records)
class FakeLotQtHist:
pass
class FakeLotQtType:
@classmethod
def search(cls, domain):
return []
pool = Mock()
pool.get.side_effect = lambda name: {
'fee.lots': FakeFeeLots,
'lot.qt.hist': FakeLotQtHist,
'lot.qt.type': FakeLotQtType,
'lot.lot': FakeLot,
'lot.qt': Mock(search=Mock(return_value=[])),
}[name]
line = Mock(
id=11,
product=Mock(type='goods'),
lots=[],
fees=[SimpleNamespace(id=7)],
unit=Mock(),
quantity_theorical=Decimal('25'),
created_by_code=False,
price_components=[],
fee_=None,
)
line.check_pricing.return_value = None
line.check_targeted_qt_tolerance.return_value = None
with patch.object(sale_module, 'Pool', return_value=pool), \
patch.object(
sale_module.SaleLine, '_has_invalid_delivery_period',
return_value=False), \
patch.object(
sale_module.SaleLine,
'_sync_initial_quantity_from_theorical',
return_value=False), \
patch.object(sale_module.SaleLine, 'save'), \
patch.object(sale_module.Pnl, 'generate_from_sale_line'):
sale_module.SaleLine.validate([line])
self.assertEqual(len(saved_lots), 1)
self.assertEqual(len(created_fee_lots), 1)
self.assertEqual(created_fee_lots[0].sale_line, line.id)
def test_custom_duplicate_match_pair_lots_links_new_quantities(self):
'custom duplicate matches the new purchase and sale lot.qt rows'
saved = []
deleted = []
checked_lines = []
purchase_lqt = SimpleNamespace(
lot_s=None,
lot_quantity=Decimal('0'),
)
sale_lqt = SimpleNamespace(
lot_p=None,
lot_quantity=Decimal('100'),
)
class FakeLotQt:
@classmethod
def search(cls, domain):
if ('lot_p', '=', 101) in domain:
return [purchase_lqt]
if ('lot_s', '=', 202) in domain:
return [sale_lqt]
return []
@classmethod
def save(cls, records):
saved.extend(records)
@classmethod
def delete(cls, records):
deleted.extend(records)
class FakeLot:
@classmethod
def assert_lines_quantity_consistency(cls, lines):
checked_lines.extend(lines)
pool = Mock()
pool.get.side_effect = lambda name: {
'lot.qt': FakeLotQt,
'lot.lot': FakeLot,
}[name]
purchase_line = SimpleNamespace(__name__='purchase.line')
sale_line = SimpleNamespace(__name__='sale.line')
with patch.object(duplicate_module, 'Pool', return_value=pool):
duplicate_module.TradeCustomDuplicate._match_duplicate_pair_lots(
purchase_line, SimpleNamespace(id=101),
sale_line, SimpleNamespace(id=202),
Decimal('100'))
self.assertEqual(purchase_lqt.lot_s, 202)
self.assertEqual(purchase_lqt.lot_quantity, Decimal('100.00000'))
self.assertEqual(saved, [purchase_lqt])
self.assertEqual(deleted, [sale_lqt])
self.assertEqual(checked_lines, [purchase_line, sale_line])
def test_lot_matching_rejects_purchase_quantity_above_available(self):
'apply matching cannot consume more than available purchase quantity'
class LotQtMock:
def __call__(self, _id):
return Mock(lot_quantity=Decimal('100'))
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
purchase_lot = Mock(
lot_matched_qt=Decimal('101'), lot_r_id=1)
with patch.object(lot_module, 'Pool', return_value=pool):
with self.assertRaises(UserError):
lot_module.LotQt.match_lots([purchase_lot], [])
def test_lot_matching_available_includes_tolerance_remaining(self):
'matching can use remaining purchase tolerance above open quantity'
purchase = Mock(tol_max=Decimal('10'))
line = Mock(
purchase=purchase, inherit_tol=True,
quantity_theorical=Decimal('100'), unit=None)
vlot = Mock(id=10, line=line)
lqt = Mock(lot_p=vlot, lot_quantity=Decimal('60'))
matched_lqt = Mock(lot_quantity=Decimal('40'), lot_unit=None)
with patch.object(
lot_module.LotQt, 'search', return_value=[matched_lqt]):
self.assertEqual(
lot_module.LotQt._matching_tolerated_available(
lqt, 'purchase'),
Decimal('70.00000'))
def test_lot_matching_accepts_purchase_quantity_within_tolerance(self):
'apply matching can consume purchase quantity up to tolerance max'
purchase = Mock(tol_max=Decimal('10'))
line = Mock(
purchase=purchase, inherit_tol=True,
quantity_theorical=Decimal('100'), unit=None)
vlot = Mock(id=10, line=line)
lqt = Mock(lot_p=vlot, lot_s=None, lot_quantity=Decimal('100'))
class LotQtMock:
def __call__(self, _id):
return lqt
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
purchase_lot = Mock(
lot_matched_qt=Decimal('105'), lot_r_id=1)
with patch.object(lot_module, 'Pool', return_value=pool), \
patch.object(lot_module.LotQt, 'search', return_value=[]):
lot_module.LotQt.match_lots([purchase_lot], [])
def test_lot_matching_gauge_uses_entered_quantity_to_match(self):
'go to matching gauge projects tolerance from qt to match'
matching_lot = lot_module.LotMatchingLot()
matching_lot.lot_theoretical_qt = Decimal('100')
matching_lot.lot_already_matched_qt = Decimal('40')
matching_lot.lot_matched_qt = Decimal('65')
matching_lot.lot_tolerance_min = Decimal('-5')
matching_lot.lot_tolerance_max = Decimal('10')
self.assertEqual(
matching_lot.on_change_with_lot_tolerance_used(),
Decimal('5.00000'))
def test_go_matching_defaults_selected_open_lot_qts(self):
'go to matching only preloads selected unmatched open lot.qt rows'
purchase = Mock(id=10, party=Mock(id=20))
sale = Mock(id=30, party=Mock(id=40))
product = Mock(id=50)
purchase_lot = Mock(
id=101,
line=Mock(
purchase=purchase, inherit_tol=False,
quantity_theorical=Decimal('100'),
targeted_qt=Decimal('96'), tol_min=0, tol_max=0),
sale_line=None,
lot_type='virtual', lot_product=product)
sale_lot = Mock(
id=102, line=None,
sale_line=Mock(
sale=sale, inherit_tol=False,
quantity_theorical=Decimal('80'),
targeted_qt=Decimal('84'), tol_min=0, tol_max=0),
lot_type='virtual', lot_product=product)
matched_sale_lot = Mock(
id=103, line=None,
sale_line=Mock(
sale=sale, inherit_tol=False,
quantity_theorical=Decimal('20'), tol_min=0, tol_max=0),
lot_type='virtual', lot_product=product)
lot_qts = {
1: Mock(
id=1, lot_p=purchase_lot, lot_s=None,
lot_quantity=Decimal('100'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None),
2: Mock(
id=2, lot_p=None, lot_s=sale_lot,
lot_quantity=Decimal('80'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None),
3: Mock(
id=3, lot_p=purchase_lot, lot_s=matched_sale_lot,
lot_quantity=Decimal('20'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None),
}
class LotQtMock:
def __call__(self, _id):
return lot_qts[_id]
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else Mock())
transaction = Mock()
transaction.context = {
'active_ids': [10000001, 10000002, 10000003],
}
with patch.object(lot_module, 'Pool', return_value=pool):
with patch.object(
lot_module, 'Transaction', return_value=transaction):
result = lot_module.LotGoMatching().default_match([])
self.assertEqual(result['qt_type'], 'all')
self.assertEqual(len(result['lot_p']), 1)
self.assertEqual(len(result['lot_s']), 1)
self.assertEqual(result['lot_p'][0]['lot_r_id'], 1)
self.assertEqual(result['lot_p'][0]['lot_quantity'], Decimal('100'))
self.assertEqual(result['lot_p'][0]['lot_targeted_qt'], Decimal('96'))
self.assertEqual(result['lot_p'][0]['lot_qt_min'], Decimal('100.00000'))
self.assertEqual(result['lot_p'][0]['lot_qt_max'], Decimal('100.00000'))
self.assertEqual(result['lot_p'][0]['lot_cp'], 20)
self.assertEqual(result['lot_s'][0]['lot_r_id'], 2)
self.assertEqual(result['lot_s'][0]['lot_quantity'], Decimal('80'))
self.assertEqual(result['lot_s'][0]['lot_targeted_qt'], Decimal('84'))
self.assertEqual(result['lot_s'][0]['lot_qt_min'], Decimal('80.00000'))
self.assertEqual(result['lot_s'][0]['lot_qt_max'], Decimal('80.00000'))
self.assertEqual(result['lot_s'][0]['lot_cp'], 40)
def test_go_matching_defaults_selected_physical_purchase_lot(self):
'go to matching preloads selected unmatched physical purchase lots'
purchase = Mock(id=10, party=Mock(id=20))
sale = Mock(id=30, party=Mock(id=40))
product = Mock(id=50)
physical_lot = Mock(
id=101,
line=Mock(
purchase=purchase, inherit_tol=False,
quantity_theorical=Decimal('100'),
targeted_qt=Decimal('96'), tol_min=0, tol_max=0),
sale_line=None,
lot_type='physic',
lot_product=product,
lot_shipment_in=None,
lot_shipment_internal=None,
lot_shipment_out=None)
physical_lot.get_current_quantity_converted.return_value = (
Decimal('70'))
sale_lot = Mock(
id=102, line=None,
sale_line=Mock(
sale=sale, inherit_tol=False,
quantity_theorical=Decimal('80'),
targeted_qt=Decimal('84'), tol_min=0, tol_max=0),
lot_type='virtual', lot_product=product)
sale_lqt = Mock(
id=2, lot_p=None, lot_s=sale_lot,
lot_quantity=Decimal('80'),
lot_shipment_in=None, lot_shipment_internal=None,
lot_shipment_out=None)
class LotQtMock:
def __call__(self, _id):
return sale_lqt
class LotMock:
def __call__(self, _id):
return physical_lot
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else LotMock())
transaction = Mock()
transaction.context = {
'active_ids': [101, 10000002],
}
with patch.object(lot_module, 'Pool', return_value=pool):
with patch.object(
lot_module, 'Transaction', return_value=transaction):
result = lot_module.LotGoMatching().default_match([])
self.assertEqual(len(result['lot_p']), 1)
self.assertEqual(len(result['lot_s']), 1)
self.assertEqual(result['lot_p'][0]['lot_id'], 101)
self.assertIsNone(result['lot_p'][0]['lot_r_id'])
self.assertEqual(result['lot_p'][0]['lot_type'], 'physic')
self.assertEqual(result['lot_p'][0]['lot_quantity'], Decimal('70'))
self.assertEqual(result['lot_p'][0]['lot_targeted_qt'], Decimal('96'))
self.assertEqual(result['lot_p'][0]['lot_cp'], 20)
self.assertEqual(result['lot_s'][0]['lot_r_id'], 2)
def test_go_matching_requires_purchase_and_sale_selection(self):
'go to matching requires one purchase and one sale row'
wizard = lot_module.LotGoMatching()
wizard.match = Mock(lot_p=[], lot_s=[Mock()])
with self.assertRaises(UserError):
wizard.transition_matching()
def test_physical_purchase_matching_keeps_empty_sale_lot_qt(self):
'physical purchase matching does not fail when sale lot_qt is empty'
physical_lot = Mock(
id=101,
lot_type='physic',
sale_line=None)
physical_lot.get_current_quantity_converted.return_value = (
Decimal('10'))
physical_lot.getVlot_p.return_value = Mock()
physical_lot.updateVirtualPart.return_value = True
sale_virtual_lot = Mock(
sale_line=Mock(),
lot_quantity=Decimal('9'),
lot_qt=None)
sale_virtual_lot.getVlot_s.return_value = sale_virtual_lot
sale_lqt = Mock(
lot_p=None,
lot_s=sale_virtual_lot,
lot_quantity=Decimal('9'),
lot_unit=None)
class LotQtMock:
def __call__(self, _id):
return sale_lqt
@staticmethod
def search(_domain):
return []
@staticmethod
def save(_records):
return None
class LotMock:
def __call__(self, _id):
return physical_lot
@staticmethod
def save(_records):
return None
pool = Mock()
pool.get.side_effect = (
lambda name: LotQtMock() if name == 'lot.qt' else LotMock())
purchase_match = Mock(
lot_id=101,
lot_r_id=None,
lot_matched_qt=Decimal('1'))
sale_match = Mock(
lot_r_id=2,
lot_matched_qt=Decimal('1'))
with patch.object(lot_module, 'Pool', return_value=pool), \
patch.object(lot_module.LotQt, 'search', return_value=[]):
lot_module.LotQt.match_lots([purchase_match], [sale_match])
self.assertIsNone(sale_virtual_lot.lot_qt)
self.assertEqual(sale_virtual_lot.lot_quantity, Decimal('8'))
def test_purchase_tolerance_used_is_weighted_line_average(self):
'purchase tolerance gauge averages line gauges by theoretical quantity'
unit = Mock()
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('105')
physical_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('100'),
lots=[physical])
virtual = Mock(id=10, lot_type='virtual')
open_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('300'),
lots=[virtual])
purchase = purchase_module.Purchase()
purchase.lines = [physical_line, open_line]
purchase.tol_min = Decimal('5')
purchase.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('270'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(purchase_module, 'Pool', return_value=pool):
self.assertEqual(
purchase.get_tolerance_used('tolerance_used'),
Decimal('-6.25000'))
self.assertEqual(
purchase.get_tolerance_min('tolerance_min'),
Decimal('-5'))
self.assertEqual(
purchase.get_tolerance_max('tolerance_max'),
Decimal('10'))
def test_sale_tolerance_used_is_weighted_line_average(self):
'sale tolerance gauge averages line gauges by theoretical quantity'
unit = Mock()
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('105')
physical_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('100'),
lots=[physical])
virtual = Mock(id=20, lot_type='virtual')
open_line = Mock(
type='line', unit=unit, quantity_theorical=Decimal('300'),
lots=[virtual])
sale = sale_module.Sale()
sale.lines = [physical_line, open_line]
sale.tol_min = Decimal('5')
sale.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('-270'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(sale_module, 'Pool', return_value=pool):
self.assertEqual(
sale.get_tolerance_used('tolerance_used'),
Decimal('-6.25000'))
self.assertEqual(
sale.get_tolerance_min('tolerance_min'),
Decimal('-5'))
self.assertEqual(
sale.get_tolerance_max('tolerance_max'),
Decimal('10'))
def test_purchase_line_tolerance_uses_physical_lots_when_present(self):
'purchase line tolerance uses the editable targeted quantity'
unit = Mock()
virtual = Mock(id=10, lot_type='virtual')
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('105')
line = purchase_module.Line()
line.unit = unit
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('105')
line.lots = [virtual, physical]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('80'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(purchase_module, 'Pool', return_value=pool):
self.assertEqual(
line.get_tolerance_used('tolerance_used'),
Decimal('5.00000'))
lot_qt_model.search.assert_not_called()
def test_sale_line_tolerance_uses_open_lot_qt_without_physical_lots(self):
'sale line tolerance uses the editable targeted quantity'
unit = Mock()
virtual = Mock(id=20, lot_type='virtual')
line = sale_module.SaleLine()
line.unit = unit
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('95')
line.lots = [virtual]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('-95'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(sale_module, 'Pool', return_value=pool):
self.assertEqual(
line.get_tolerance_used('tolerance_used'),
Decimal('-5.00000'))
def test_purchase_line_targeted_qt_blocks_above_tolerance(self):
'purchase line targeted quantity must stay inside tolerance bounds'
physical = Mock(lot_type='physic')
physical.get_current_quantity_converted.return_value = Decimal('111')
line = purchase_module.Line()
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('111')
line.lots = [physical]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
with self.assertRaises(UserError):
line.check_targeted_qt_tolerance()
def test_sale_line_targeted_qt_blocks_below_tolerance(self):
'sale line targeted quantity must not fall below tolerance minimum'
unit = Mock()
virtual = Mock(id=20, lot_type='virtual')
line = sale_module.SaleLine()
line.unit = unit
line.quantity_theorical = Decimal('100')
line.quantity = Decimal('100')
line.targeted_qt = Decimal('94')
line.lots = [virtual]
line.inherit_tol = False
line.tol_min = Decimal('5')
line.tol_max = Decimal('10')
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(lot_quantity=Decimal('-94'), lot_unit=unit)]
pool = Mock()
pool.get.return_value = lot_qt_model
with patch.object(sale_module, 'Pool', return_value=pool):
with self.assertRaises(UserError):
line.check_targeted_qt_tolerance()
def test_purchase_line_initial_targeted_qt_defaults_to_contract_quantity(self):
'purchase line targeted quantity defaults to contractual quantity'
values = {'quantity_theorical': Decimal('100')}
purchase_module.Line._set_initial_quantity_values(values)
self.assertEqual(values['targeted_qt'], Decimal('100.00000'))
def test_sale_line_initial_targeted_qt_defaults_to_contract_quantity(self):
'sale line targeted quantity defaults to contractual quantity'
values = {'quantity_theorical': Decimal('100')}
sale_module.SaleLine._set_initial_quantity_values(values)
self.assertEqual(values['targeted_qt'], Decimal('100.00000'))
def test_lot_context_group_defaults_to_all(self):
'lots management opens without grouping by physical lot'
self.assertEqual(lot_module.LotContext.default_group(), 'all')
def test_contract_detail_defaults_sale_locations_from_dropship_purchase(self):
'create contracts copies supplier-to-customer locations from purchase to sale'
supplier = Mock(id=1, type='supplier')
customer = Mock(id=2, type='customer')
purchase = Mock(from_location=supplier, to_location=customer)
lqt = Mock(
lot_p=Mock(line=Mock(purchase=purchase)),
lot_s=None)
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertEqual(lot_module.ContractDetail.default_from_location(), 1)
self.assertEqual(lot_module.ContractDetail.default_to_location(), 2)
def test_contract_detail_defaults_sale_from_purchase_stock_destination(self):
'create contracts uses purchase stock destination as sale source'
supplier = Mock(id=1, type='supplier')
storage = Mock(id=3, type='storage')
purchase = Mock(from_location=supplier, to_location=storage)
lqt = Mock(
lot_p=Mock(line=Mock(purchase=purchase)),
lot_s=None)
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertEqual(lot_module.ContractDetail.default_from_location(), 3)
self.assertIsNone(lot_module.ContractDetail.default_to_location())
def test_contract_detail_defaults_purchase_locations_from_dropship_sale(self):
'create contracts copies supplier-to-customer locations from sale to purchase'
supplier = Mock(id=1, type='supplier')
customer = Mock(id=2, type='customer')
sale = Mock(from_location=supplier, to_location=customer)
lqt = Mock(
lot_p=None,
lot_s=Mock(sale_line=Mock(sale=sale)))
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertEqual(lot_module.ContractDetail.default_from_location(), 1)
self.assertEqual(lot_module.ContractDetail.default_to_location(), 2)
def test_contract_detail_defaults_purchase_to_sale_stock_source(self):
'create contracts uses sale stock source as purchase destination'
storage = Mock(id=3, type='storage')
customer = Mock(id=2, type='customer')
sale = Mock(from_location=storage, to_location=customer)
lqt = Mock(
lot_p=None,
lot_s=Mock(sale_line=Mock(sale=sale)))
with patch.object(
lot_module.ContractDetail, '_get_lqt_from_context',
return_value=lqt):
self.assertIsNone(lot_module.ContractDetail.default_from_location())
self.assertEqual(lot_module.ContractDetail.default_to_location(), 3)
def test_contract_detail_hides_melya_company_fields(self):
'create contracts mirrors Melya company field visibility'
company = Mock(party=Mock(name='MELYA'))
company_model = Mock(return_value=company)
transaction = Mock()
transaction.context = {'company': 42}
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock, \
patch('trytond.modules.purchase_trade.lot.Transaction',
return_value=transaction):
PoolMock.return_value.get.return_value = company_model
self.assertTrue(
lot_module.ContractDetail.default_company_visible())
def test_sale_report_price_lines_basis_displays_premium_only(self):
'basis report pricing displays only the premium in templates'
Sale = Pool().get('sale.sale')
line = Mock()
line.type = 'line'
line.price_type = 'basis'
line.enable_linked_currency = True
line.linked_currency = Mock(rec_name='USC')
line.linked_unit = Mock(rec_name='POUND')
line.unit = Mock(rec_name='MT')
line.unit_price = Decimal('1598.3495')
line.linked_price = Decimal('72.5000')
line.premium = Decimal('8.3000')
line.get_pricing_text = 'ON ICE Cotton #2 MARCH 2026'
sale = Sale()
sale.currency = Mock(rec_name='USD')
sale.lines = [line]
self.assertEqual(
sale.report_price_lines,
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026')
def test_sale_report_net_and_gross_sum_all_lines(self):
'sale report totals aggregate every line instead of the first one only'
Sale = Pool().get('sale.sale')
def make_lot(quantity):
lot = Mock()
lot.lot_type = 'physic'
lot.get_current_quantity.return_value = Decimal(quantity)
lot.get_current_gross_quantity.return_value = Decimal(quantity)
return lot
line_a = Mock(type='line', quantity=Decimal('1000'))
line_a.lots = [make_lot('1000')]
line_b = Mock(type='line', quantity=Decimal('1000'))
line_b.lots = [make_lot('1000')]
sale = Sale()
sale.lines = [line_a, line_b]
self.assertEqual(sale.report_net, Decimal('2000'))
self.assertEqual(sale.report_gross, Decimal('2000'))
def test_sale_report_trade_blocks_use_lot_current_quantity(self):
'sale trade blocks use current lot quantity for quantity display'
Sale = Pool().get('sale.sale')
lot = Mock()
lot.lot_type = 'physic'
lot.get_current_quantity.return_value = Decimal('950')
line = Mock()
line.type = 'line'
line.lots = [lot]
line.quantity = Decimal('1000')
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description='MARCH 2026')
line.price_type = 'priced'
line.linked_currency = Mock(rec_name='USC')
line.linked_unit = Mock(rec_name='POUND')
line.linked_price = Decimal('8.3000')
line.unit_price = Decimal('0')
line.get_pricing_text = 'ON ICE Cotton #2 MARCH 2026'
sale = Sale()
sale.currency = Mock(rec_name='USD')
sale.lines = [line]
self.assertEqual(
sale.report_trade_blocks,
[(
'950.0 MT (NINE HUNDRED AND FIFTY METRIC TONS) - MARCH 2026',
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026',
)])
def test_sale_report_uses_single_virtual_lot_hist_when_no_physical(self):
'sale report uses the unique virtual lot hist when no physical lot exists'
Sale = Pool().get('sale.sale')
virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
virtual.get_hist_quantity.return_value = (
Decimal('930'),
Decimal('0'),
)
line = Mock(type='line', quantity=Decimal('1000'))
line.lots = [virtual]
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description='MARCH 2026')
sale = Sale()
sale.lines = [line]
self.assertEqual(sale.report_net, Decimal('930'))
self.assertEqual(sale.report_gross, Decimal('930'))
self.assertEqual(sale.report_total_quantity, '930.0')
self.assertEqual(sale.report_quantity_unit_upper, 'LBS')
self.assertEqual(
sale.report_quantity_lines,
'930.0 LBS (NINE HUNDRED AND THIRTY POUNDS) - MARCH 2026')
def test_sale_report_prefers_physical_lot_hist_over_virtual(self):
'sale report prioritizes physical lot hist values over virtual ones'
Sale = Pool().get('sale.sale')
virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS'))
virtual.get_hist_quantity.return_value = (
Decimal('930'),
Decimal('940'),
)
physical = Mock(lot_type='physic', lot_unit_line=Mock(rec_name='LBS'))
physical.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
line = Mock(type='line', quantity=Decimal('1000'))
line.lots = [virtual, physical]
line.unit = Mock(rec_name='MT')
line.del_period = Mock(description='MARCH 2026')
sale = Sale()
sale.lines = [line]
self.assertEqual(sale.report_net, Decimal('950'))
self.assertEqual(sale.report_gross, Decimal('980'))
self.assertEqual(sale.report_total_quantity, '950.0')
self.assertEqual(sale.report_quantity_unit_upper, 'LBS')
self.assertEqual(
sale.report_quantity_lines,
'950.0 LBS (NINE HUNDRED AND FIFTY POUNDS) - MARCH 2026')
def test_invoice_report_note_title_uses_sale_direction(self):
'sale final note title is inverted from the raw amount sign'
Invoice = Pool().get('account.invoice')
debit = Invoice()
debit.type = 'out'
debit.total_amount = Decimal('10')
self.assertEqual(debit.report_note_title, 'Debit Note')
credit = Invoice()
credit.type = 'out'
credit.total_amount = Decimal('-10')
self.assertEqual(credit.report_note_title, 'Credit Note')
def test_invoice_report_note_title_keeps_inverse_for_purchase(self):
'purchase final note title keeps the opposite mapping'
Invoice = Pool().get('account.invoice')
credit = Invoice()
credit.type = 'in'
credit.total_amount = Decimal('10')
self.assertEqual(credit.report_note_title, 'Credit Note')
debit = Invoice()
debit.type = 'in'
debit.total_amount = Decimal('-10')
self.assertEqual(debit.report_note_title, 'Debit Note')
def test_invoice_commission_note_uses_company_to_broker_direction(self):
'commission note title follows company to broker correction direction'
Invoice = Pool().get('account.invoice')
credit = Invoice()
credit.total_amount = Decimal('-79.37')
self.assertEqual(credit.report_commission_note_title, 'Credit Note')
self.assertEqual(credit.report_commission_due_label, 'TOTAL DUE TO US')
debit = Invoice()
debit.total_amount = Decimal('79.37')
self.assertEqual(debit.report_commission_note_title, 'Debit Note')
self.assertEqual(debit.report_commission_due_label, 'TOTAL DUE TO YOU')
def test_invoice_commission_note_total_is_absolute(self):
'commission note total is displayed without the accounting sign'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice.total_amount = Decimal('-79.37')
self.assertEqual(invoice.report_commission_note_total_display, '79.37')
def test_invoice_commission_note_uses_signed_invoice_adjustment(self):
'commission note ignores the unsigned full fee amount'
Invoice = Pool().get('account.invoice')
fee = Mock()
fee.get_amount.return_value = Decimal('24329.40')
invoice = Invoice()
invoice.total_amount = Decimal('-24.60')
invoice.lines = [Mock(type='line', fee=fee)]
self.assertEqual(invoice.report_commission_note_title, 'Credit Note')
self.assertEqual(invoice.report_commission_due_label, 'TOTAL DUE TO US')
self.assertEqual(invoice.report_commission_note_total_display, '24.60')
fee.get_amount.assert_not_called()
def test_sale_provisional_invoice_delete_collects_padding_lots(self):
'deleting a draft sale provisional invoice clears its lot padding'
Invoice = Pool().get('account.invoice')
invoice = SimpleNamespace(
id=10,
type='out',
reference='Provisional',
number=None,
lines=[])
provisional_line = SimpleNamespace(invoice=invoice)
lot = SimpleNamespace(
id=20,
sale_invoice_padding=Decimal('2'),
sale_invoice_line_prov=provisional_line)
invoice.lines = [
SimpleNamespace(
type='line',
description='Pro forma',
lot=lot)]
self.assertEqual(
Invoice._sale_padding_lots_to_clear_on_delete([invoice]),
[lot])
def test_fee_invoice_delete_collects_fee_to_reset(self):
'deleting a draft fee invoice resets the linked fee to not invoiced'
Invoice = Pool().get('account.invoice')
fee = SimpleNamespace(id=20, dn_cn=None)
invoice = SimpleNamespace(
id=10,
number=None,
lines=[SimpleNamespace(fee=fee)])
reset_fees, clear_dn_cn_fees = Invoice._fee_updates_on_delete(
[invoice])
self.assertEqual(reset_fees, [fee])
self.assertEqual(clear_dn_cn_fees, [])
def test_fee_dn_cn_delete_collects_fee_to_clear_dn_cn_only(self):
'deleting a draft fee DN/CN clears only the fee DN/CN link'
Invoice = Pool().get('account.invoice')
invoice = SimpleNamespace(id=10, number=None, lines=[])
fee = SimpleNamespace(id=20, dn_cn=invoice)
invoice.lines = [SimpleNamespace(fee=fee)]
reset_fees, clear_dn_cn_fees = Invoice._fee_updates_on_delete(
[invoice])
self.assertEqual(reset_fees, [])
self.assertEqual(clear_dn_cn_fees, [fee])
def test_lot_invoice_message_mentions_processed_fees(self):
'lot invoice success message mentions selected fee invoicing'
wizard = lot_module.LotInvoice()
wizard._invoiced_fee_count = 2
self.assertEqual(
wizard.default_message(None)['message'],
'Invoice created. Fee invoices: 2.')
def test_invoice_report_net_sums_signed_invoice_lines(self):
'invoice report net uses the signed differential from invoice lines'
Invoice = Pool().get('account.invoice')
line_a = Mock(type='line', quantity=Decimal('1000'))
line_b = Mock(type='line', quantity=Decimal('-200'))
invoice = Invoice()
invoice.lines = [line_a, line_b]
self.assertEqual(invoice.report_net, Decimal('800'))
def test_invoice_report_weights_use_current_lot_hist_values(self):
'invoice net and gross weights come from the current lot hist entry'
Invoice = Pool().get('account.invoice')
unit = Mock(rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit)
lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
line = Mock(type='line', quantity=Decimal('1000'), lot=lot, unit=Mock(rec_name='MT'))
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('950'))
self.assertEqual(invoice.report_gross, Decimal('980'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
self.assertEqual(
invoice.report_quantity_lines,
'950.00 LBS (950.00 LBS)')
def test_invoice_report_weights_prefer_invoice_line_snapshot(self):
'invoice report weights use the invoicing snapshot when available'
Invoice = Pool().get('account.invoice')
unit = Mock(rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit)
lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
snapshot = Mock(
net_quantity=Decimal('900'),
gross_quantity=Decimal('920'),
unit=unit,
)
line = Mock(
type='line',
quantity=Decimal('1000'),
lot=lot,
unit=Mock(rec_name='MT'),
lot_weight_snapshots=[snapshot],
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('900'))
self.assertEqual(invoice.report_gross, Decimal('920'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
self.assertEqual(
invoice.report_quantity_lines,
'900.00 LBS (900.00 LBS)')
def test_invoice_report_proforma_snapshot_adds_sale_padding(self):
'ICT provisional report displays snapshot weights plus sale padding'
Invoice = Pool().get('account.invoice')
unit = Mock(id=1, rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=unit, sale_invoice_padding=Decimal('10'))
snapshot = Mock(
net_quantity=Decimal('900'),
gross_quantity=Decimal('920'),
unit=unit,
)
line = Mock(
type='line',
description='Pro forma',
invoice_type='out',
quantity=Decimal('1010'),
lot=lot,
unit=unit,
lot_weight_snapshots=[snapshot],
)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_net, Decimal('910'))
self.assertEqual(invoice.report_gross, Decimal('930'))
self.assertEqual(
invoice.report_quantity_lines,
'910.00 LBS (910.00 LBS)')
def test_invoice_validation_creates_weight_snapshot_for_report_lines(self):
'invoice validation snapshots lot weights for pro forma and final lines'
Invoice = Pool().get('account.invoice')
lot_ref = Mock(id=10)
lot = Mock(id=10)
proforma = Mock(
type='line',
description='Pro forma',
quantity=Decimal('1000'),
lot=lot_ref,
)
final = Mock(
type='line',
description='Final',
quantity=Decimal('1000'),
lot=lot_ref,
)
invoice = Invoice()
invoice.lines = [proforma, final]
snapshot_model = Mock()
lot_model = Mock(return_value=lot)
def get_model(name):
if name == 'account.invoice.line.lot.weight':
return snapshot_model
if name == 'lot.lot':
return lot_model
raise AssertionError(name)
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = get_model
invoice._create_lot_weight_snapshots()
self.assertEqual(
snapshot_model.create_for_invoice_line.call_args_list,
[call(proforma, lot), call(final, lot)])
def test_invoice_report_lbs_converts_kilogram_to_lbs(self):
'invoice lbs helper converts kilogram quantities with the proper uom ratio'
Invoice = Pool().get('account.invoice')
kg = Mock(id=1, rec_name='KILOGRAM', symbol='KG')
lbs = Mock(id=2, rec_name='LBS', symbol='LBS')
lot = Mock(lot_unit_line=kg)
lot.get_hist_quantity.return_value = (
Decimal('999995'),
Decimal('999995'),
)
line = Mock(type='line', quantity=Decimal('999995'), lot=lot, unit=kg)
invoice = Invoice()
invoice.lines = [line]
uom_model = Mock()
uom_model.search.return_value = [lbs]
uom_model.compute_qty.side_effect = (
lambda from_unit, qty, to_unit: qty * 2.20462
)
with patch('trytond.modules.purchase_trade.invoice.Pool') as PoolMock:
PoolMock.return_value.get.return_value = uom_model
self.assertEqual(invoice.report_lbs, Decimal('2204608.98'))
self.assertEqual(
invoice.report_quantity_lines,
'999995.00 KILOGRAM (2204608.98 LBS)')
self.assertEqual(invoice.report_net_display, '999995.00')
self.assertEqual(invoice.report_lbs_display, '2204608.98')
def test_invoice_report_weights_keep_line_sign_with_lot_hist_values(self):
'invoice lot hist values keep the invoice line sign for final notes'
Invoice = Pool().get('account.invoice')
positive_lot = Mock(lot_unit_line=Mock(rec_name='LBS'))
positive_lot.get_hist_quantity.return_value = (
Decimal('950'),
Decimal('980'),
)
negative_lot = Mock(lot_unit_line=Mock(rec_name='LBS'))
negative_lot.get_hist_quantity.return_value = (
Decimal('150'),
Decimal('160'),
)
positive = Mock(type='line', quantity=Decimal('1000'), lot=positive_lot)
negative = Mock(type='line', quantity=Decimal('-200'), lot=negative_lot)
invoice = Invoice()
invoice.lines = [positive, negative]
self.assertEqual(invoice.report_net, Decimal('800'))
self.assertEqual(invoice.report_gross, Decimal('820'))
def test_invoice_report_shows_old_current_and_difference_for_same_lot_note(self):
'final note weight adjustment shows old, current and difference weights'
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,
lot_weight_snapshots=[
Mock(net_quantity=Decimal('999995'), gross_quantity=Decimal('999992'), unit=kg),
],
)
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('999995.0'))
self.assertEqual(invoice.report_gross, Decimal('999990'))
self.assertEqual(invoice.report_weight_difference, Decimal('5.0'))
self.assertEqual(invoice.report_net_display, '999995.00')
self.assertEqual(invoice.report_gross_display, '999990.00')
self.assertEqual(invoice.report_weight_difference_display, '5.00')
self.assertEqual(
invoice.report_quantity_lines.splitlines(),
[
'-999995.0 KILOGRAM (-2204608.98 LBS)',
'999990.0 KILOGRAM (2204597.95 LBS)',
])
def test_invoice_report_weights_ignore_origin_virtual_lot(self):
'invoice report falls back to invoice quantity instead of origin virtual lot'
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]
line.invoice = invoice
self.assertEqual(invoice.report_net, Decimal('1000'))
self.assertEqual(invoice.report_gross, Decimal('1000'))
self.assertEqual(invoice.report_weight_unit_upper, 'MT')
def test_invoice_report_weights_use_lot_linked_to_invoice_line(self):
'invoice uses the physical lot explicitly linked to the invoice line'
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'),
)
physical.sale_invoice_line = None
line = Mock(
id=100,
type='line',
quantity=Decimal('1000'),
lot=virtual,
unit=Mock(rec_name='MT'),
)
physical.sale_invoice_line_prov = line
physical.invoice_line = None
physical.invoice_line_prov = None
origin = Mock(lots=[virtual, physical])
line.origin = origin
invoice = Invoice()
invoice.lines = [line]
invoice.sales = [Mock(lines=[origin])]
line.invoice = invoice
self.assertEqual(invoice.report_net, Decimal('950'))
self.assertEqual(invoice.report_gross, Decimal('980'))
self.assertEqual(invoice.report_weight_unit_upper, 'LBS')
def test_invoice_report_shipment_uses_invoice_line_lot_not_first_trade_line(self):
'invoice shipment info comes from the lots linked to the invoiced line'
Invoice = Pool().get('account.invoice')
shipment_a = Mock(
id=1,
bl_date='2026-04-01',
bl_number='BL-A',
vessel=Mock(vessel_name='VESSEL A'),
from_location=Mock(rec_name='LOADING A'),
to_location=Mock(rec_name='DISCHARGE A'),
controller=Mock(rec_name='CTRL A'),
number='SI-A',
)
shipment_b = Mock(
id=2,
bl_date='2026-04-05',
bl_number='BL-B',
vessel=Mock(vessel_name='VESSEL B'),
from_location=Mock(rec_name='LOADING B'),
to_location=Mock(rec_name='DISCHARGE B'),
controller=Mock(rec_name='CTRL B'),
number='SI-B',
)
lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a)
lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b)
line_a = Mock(lots=[lot_a])
line_b = Mock(lots=[lot_b])
purchase = Mock(lines=[line_a, line_b])
invoice_line = Mock(type='line', lot=lot_b, origin=line_b)
invoice = Invoice()
invoice.purchases = [purchase]
invoice.lines = [invoice_line]
self.assertEqual(invoice.report_bl_nb, 'BL-B')
self.assertEqual(invoice.report_bl_date, '2026-04-05')
self.assertEqual(invoice.report_vessel, 'VESSEL B')
self.assertEqual(invoice.report_loading_port, 'LOADING B')
self.assertEqual(invoice.report_discharge_port, 'DISCHARGE B')
self.assertEqual(invoice.report_controller_name, 'CTRL B')
self.assertEqual(invoice.report_si_number, 'SI-B')
self.assertEqual(invoice.report_si_reference, 'REF-B')
def test_shipment_in_truck_defaults_empty_cargo_mode(self):
'truck shipment clears vessel data and uses empty cargo mode'
ShipmentIn = Pool().get('stock.shipment.in')
shipment = ShipmentIn()
shipment.transport_type = 'truck'
shipment.vessel = Mock()
shipment.cargo_mode = 'bulk'
shipment.on_change_transport_type()
self.assertIsNone(shipment.vessel)
self.assertEqual(shipment.cargo_mode, 'none')
self.assertEqual(
ShipmentIn._normalize_transport_values({
'transport_type': 'truck',
'cargo_mode': 'bulk',
'vessel': 42,
}),
{
'transport_type': 'truck',
'cargo_mode': 'none',
'vessel': None,
})
def test_shipment_in_write_ignores_empty_record_groups(self):
'shipment write normalization keeps empty internal writes as no-op'
ShipmentIn = Pool().get('stock.shipment.in')
self.assertEqual(
ShipmentIn._normalize_transport_write_args((
[],
{'cost_price': Decimal('12.34')},
)),
[])
def test_invoice_report_transportation_uses_truck_label(self):
'invoice_melya Transportation displays By Truck for truck shipments'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice._get_report_shipment = Mock(return_value=Mock(
transport_type='truck',
supplier=Mock(rec_name='SUPPLIER'),
vessel=Mock(vessel_name='VESSEL A'),
note='ignored',
))
self.assertEqual(invoice.report_transportation, 'By Truck')
def test_invoice_report_transportation_uses_vessel_name_only(self):
'invoice_melya Transportation displays only vessel name for vessels'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
invoice._get_report_shipment = Mock(return_value=Mock(
transport_type='vessel',
supplier=Mock(rec_name='SUPPLIER'),
vessel=Mock(vessel_name='VESSEL A'),
note='ignored',
))
self.assertEqual(invoice.report_transportation, 'VESSEL A')
def test_invoice_report_shipment_is_blank_if_invoice_mixes_shipments(self):
'invoice shipment fields stay empty when multiple shipments are invoiced together'
Invoice = Pool().get('account.invoice')
shipment_a = Mock(
id=1,
bl_date='2026-04-01',
bl_number='BL-A',
vessel=Mock(vessel_name='VESSEL A'),
from_location=Mock(rec_name='LOADING A'),
to_location=Mock(rec_name='DISCHARGE A'),
controller=Mock(rec_name='CTRL A'),
number='SI-A',
)
shipment_b = Mock(
id=2,
bl_date='2026-04-05',
bl_number='BL-B',
reference='REF-B',
vessel=Mock(vessel_name='VESSEL B'),
from_location=Mock(rec_name='LOADING B'),
to_location=Mock(rec_name='DISCHARGE B'),
controller=Mock(rec_name='CTRL B'),
number='SI-B',
)
lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a)
lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b)
line_a = Mock(type='line', lot=lot_a, origin=Mock(lots=[lot_a]))
line_b = Mock(type='line', lot=lot_b, origin=Mock(lots=[lot_b]))
invoice = Invoice()
invoice.lines = [line_a, line_b]
self.assertIsNone(invoice.report_bl_nb)
self.assertIsNone(invoice.report_bl_date)
self.assertEqual(invoice.report_vessel, None)
self.assertEqual(invoice.report_loading_port, '')
self.assertEqual(invoice.report_discharge_port, '')
self.assertEqual(invoice.report_controller_name, '')
self.assertEqual(invoice.report_si_number, '')
self.assertEqual(invoice.report_si_reference, '')
def test_invoice_report_nb_bale_sums_signed_line_lot_quantities(self):
'invoice reports packaging from the signed sum of line lot_qt values'
Invoice = Pool().get('account.invoice')
lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale'))
negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot)
positive = Mock(type='line', quantity=Decimal('1000'), lot=lot)
invoice = Invoice()
invoice.lines = [negative, positive]
self.assertEqual(invoice.report_nb_bale, 'NB BALES: 0')
def test_invoice_report_cndn_nb_bale_displays_unchanged_for_zero(self):
'CN/DN bale label displays Unchanged when the signed balance is zero'
Invoice = Pool().get('account.invoice')
lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale'))
negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot)
positive = Mock(type='line', quantity=Decimal('1000'), lot=lot)
invoice = Invoice()
invoice.lines = [negative, positive]
self.assertEqual(invoice.report_cndn_nb_bale, 'Unchanged')
def test_invoice_report_packaging_displays_quantity_before_unit(self):
'invoice packaging label displays quantity before package unit'
Invoice = Pool().get('account.invoice')
lot = Mock(lot_qt=Decimal('990'), lot_unit=Mock(symbol='BAGS25'))
line = Mock(type='line', quantity=Decimal('1000'), lot=lot)
invoice = Invoice()
invoice.lines = [line]
self.assertEqual(invoice.report_nb_bale, 'NB BAGS25: 990')
self.assertEqual(invoice.report_packaging, '990 BAGS25')
def test_invoice_report_positive_rate_lines_keep_positive_components(self):
'invoice final note pricing section keeps only positive component lines'
Invoice = Pool().get('account.invoice')
sale = Mock()
sale.report_price_lines = (
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026\n'
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MAY 2026'
)
invoice = Invoice()
invoice.sales = [sale]
invoice.lines = []
self.assertEqual(
invoice.report_positive_rate_lines.splitlines(),
[
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026',
'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MAY 2026',
])
def test_lot_invoice_sale_uses_sale_invoice_line_reference(self):
'sale invoicing must resolve the generated invoice from sale invoice links'
sale_invoice = Mock()
sale_invoice_line = Mock(invoice=sale_invoice)
lot = Mock(
sale_invoice_line=sale_invoice_line,
sale_invoice_line_prov=None,
invoice_line=None,
invoice_line_prov=None,
)
invoice_line = lot.sale_invoice_line or lot.sale_invoice_line_prov
self.assertIs(invoice_line.invoice, sale_invoice)
def test_lot_invoice_sale_padding_is_split_per_lot(self):
'sale provisional padding is split equally across selected lots'
lots = [Mock(id=1), Mock(id=2)]
self.assertEqual(
lot_module.LotInvoice._split_sale_padding(Decimal('1000'), lots),
{1: Decimal('500'), 2: Decimal('500')})
def test_lot_invoice_fee_action_follows_lot_invoice_stage(self):
'fee action is standard in provisional and DN/CN in final'
not_invoiced = Mock()
not_invoiced.get_invoice.return_value = None
invoiced = Mock()
invoiced.get_invoice.return_value = Mock()
self.assertEqual(
lot_module._fee_invoice_action(not_invoiced, 'prov'),
'standard')
self.assertEqual(
lot_module._fee_invoice_action(invoiced, 'prov'),
'already_invoiced')
self.assertEqual(
lot_module._fee_invoice_action(not_invoiced, 'final'),
'standard')
self.assertEqual(
lot_module._fee_invoice_action(invoiced, 'final'),
'dn_cn')
def test_lot_invoice_only_invoices_checked_fees_for_selected_side(self):
'lot invoice invoices only checked fees from purchase or sale side'
purchase_fee = Mock(id=1)
purchase_fee.get_invoice.return_value = None
ignored_purchase_fee = Mock(id=2)
ignored_purchase_fee.get_invoice.return_value = None
sale_fee = Mock(id=3)
sale_fee.get_invoice.return_value = None
wizard = SimpleNamespace(inv=SimpleNamespace(
type='purchase', action='prov',
fee_pur=[
SimpleNamespace(fee=purchase_fee, to_invoice=True),
SimpleNamespace(fee=ignored_purchase_fee, to_invoice=False),
],
fee_sale=[SimpleNamespace(fee=sale_fee, to_invoice=True)],
))
fee_model = Mock()
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
lot_module.LotInvoice._invoice_selected_fees(wizard)
fee_model.invoice.assert_called_once_with([purchase_fee])
def test_lot_invoice_final_invoices_checked_existing_fee_as_dn_cn(self):
'final lot invoice sends an already invoiced checked fee to Fee.invoice'
fee = Mock(id=1)
fee.get_invoice.return_value = Mock()
wizard = SimpleNamespace(inv=SimpleNamespace(
type='sale', action='final', fee_pur=[],
fee_sale=[SimpleNamespace(fee=fee, to_invoice=True)],
))
fee_model = Mock()
with patch('trytond.modules.purchase_trade.lot.Pool') as PoolMock:
PoolMock.return_value.get.return_value = fee_model
lot_module.LotInvoice._invoice_selected_fees(wizard)
fee_model.invoice.assert_called_once_with([fee])
@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