diff --git a/modules/purchase_trade/configuration.py b/modules/purchase_trade/configuration.py index 1f7bf7d..6bfbeaa 100644 --- a/modules/purchase_trade/configuration.py +++ b/modules/purchase_trade/configuration.py @@ -12,22 +12,42 @@ class PurchaseConfiguration(metaclass=PoolMeta): allow_modification_after_validation = fields.Boolean( "Autorise modification after validation") + auto_hedging = fields.Boolean("Auto hedge") + auto_hedging_over = fields.Boolean("Over hedge") @classmethod def default_allow_modification_after_validation(cls): return False + @classmethod + def default_auto_hedging(cls): + return False + + @classmethod + def default_auto_hedging_over(cls): + return False + class SaleConfiguration(metaclass=PoolMeta): __name__ = 'sale.configuration' allow_modification_after_validation = fields.Boolean( "Autorise modification after validation") + auto_hedging = fields.Boolean("Auto hedge") + auto_hedging_over = fields.Boolean("Over hedge") @classmethod def default_allow_modification_after_validation(cls): return False + @classmethod + def default_auto_hedging(cls): + return False + + @classmethod + def default_auto_hedging_over(cls): + return False + class AccountConfiguration(metaclass=PoolMeta): __name__ = 'account.configuration' diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 6764154..974b0da 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -9,7 +9,7 @@ from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder from trytond.model import (ModelSQL, ModelView) from trytond.tools import (cursor_dict, is_full_text, lstrip_wildcard) from trytond.transaction import Transaction, inactive_records -from decimal import getcontext, Decimal, ROUND_HALF_UP +from decimal import getcontext, Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr from sql.conditionals import Case, Coalesce from sql import Column, Literal @@ -2903,6 +2903,60 @@ class Line(metaclass=PoolMeta): lot.lot_unit = packing_unit Lot.save([lot]) + @classmethod + def _auto_hedge_configuration(cls): + Configuration = Pool().get('purchase.configuration') + configurations = Configuration.search([], limit=1) + if configurations: + return configurations[0] + + @classmethod + def _auto_hedge_contract_count(cls, line, over_hedge=False): + price_index = getattr(line, 'coffee_market_reference', None) + if not price_index or not getattr(line, 'unit', None): + return 0 + quantity = Decimal(str( + getattr(line, 'quantity_theorical', None) + or getattr(line, 'quantity', None) + or 0)) + if quantity <= 0: + return 0 + contract_quantity = Decimal(str(price_index.get_qt(1, line.unit) or 0)) + if contract_quantity <= 0: + return 0 + rounding = ROUND_CEILING if over_hedge else ROUND_FLOOR + return int((quantity / contract_quantity).to_integral_value( + rounding=rounding)) + + @classmethod + def _ensure_auto_hedge_derivative(cls, line): + config = cls._auto_hedge_configuration() + if not config or not getattr(config, 'auto_hedging', False): + return + if getattr(line, 'derivatives', None): + return + price_index = getattr(line, 'coffee_market_reference', None) + nb_ct = cls._auto_hedge_contract_count( + line, over_hedge=getattr(config, 'auto_hedging_over', False)) + if not price_index or nb_ct <= 0: + return + Derivative = Pool().get('derivative.derivative') + Date = Pool().get('ir.date') + quantity = price_index.get_qt(nb_ct, line.unit) + Derivative.create([{ + 'purchase': line.purchase.id if line.purchase else None, + 'line': line.id, + 'product': line.product.id if line.product else None, + 'party': line.purchase.party.id + if line.purchase and line.purchase.party else None, + 'price_index': price_index.id, + 'nb_ct': nb_ct, + 'price': getattr(line, 'coffee_market_price', None), + 'direction': 'short', + 'trade_date': Date.today(), + 'open_qty': quantity, + }]) + @classmethod def write(cls, *args): actions = iter(args) @@ -3135,6 +3189,7 @@ class Line(metaclass=PoolMeta): fl.line = line.id FeeLots.save([fl]) cls._sync_virtual_lot_packing(line) + cls._ensure_auto_hedge_derivative(line) if line.fee_: if not line.fee_.purchase: diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index a3b3b94..fc56101 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -7,7 +7,7 @@ from trytond.model import (ModelSQL, ModelView) from trytond.i18n import gettext from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction from trytond.transaction import Transaction, inactive_records -from decimal import getcontext, Decimal, ROUND_HALF_UP +from decimal import getcontext, Decimal, ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr from sql.conditionals import Case from sql import Column, Literal @@ -3023,6 +3023,60 @@ class SaleLine(metaclass=PoolMeta): lot.lot_qt = packing_count lot.lot_unit = packing_unit Lot.save([lot]) + + @classmethod + def _auto_hedge_configuration(cls): + Configuration = Pool().get('sale.configuration') + configurations = Configuration.search([], limit=1) + if configurations: + return configurations[0] + + @classmethod + def _auto_hedge_contract_count(cls, line, over_hedge=False): + price_index = getattr(line, 'coffee_market_reference', None) + if not price_index or not getattr(line, 'unit', None): + return 0 + quantity = Decimal(str( + getattr(line, 'quantity_theorical', None) + or getattr(line, 'quantity', None) + or 0)) + if quantity <= 0: + return 0 + contract_quantity = Decimal(str(price_index.get_qt(1, line.unit) or 0)) + if contract_quantity <= 0: + return 0 + rounding = ROUND_CEILING if over_hedge else ROUND_FLOOR + return int((quantity / contract_quantity).to_integral_value( + rounding=rounding)) + + @classmethod + def _ensure_auto_hedge_derivative(cls, line): + config = cls._auto_hedge_configuration() + if not config or not getattr(config, 'auto_hedging', False): + return + if getattr(line, 'derivatives', None): + return + price_index = getattr(line, 'coffee_market_reference', None) + nb_ct = cls._auto_hedge_contract_count( + line, over_hedge=getattr(config, 'auto_hedging_over', False)) + if not price_index or nb_ct <= 0: + return + Derivative = Pool().get('derivative.derivative') + Date = Pool().get('ir.date') + quantity = price_index.get_qt(nb_ct, line.unit) + Derivative.create([{ + 'sale': line.sale.id if line.sale else None, + 'sale_line': line.id, + 'product': line.product.id if line.product else None, + 'party': line.sale.party.id + if line.sale and line.sale.party else None, + 'price_index': price_index.id, + 'nb_ct': nb_ct, + 'price': getattr(line, 'coffee_market_price', None), + 'direction': 'long', + 'trade_date': Date.today(), + 'open_qty': quantity, + }]) @classmethod def validate(cls, salelines): @@ -3083,6 +3137,7 @@ class SaleLine(metaclass=PoolMeta): fl.sale_line = line.id FeeLots.save([fl]) cls._sync_virtual_lot_packing(line) + cls._ensure_auto_hedge_derivative(line) #generate valuation for purchase and sale LotQt = Pool().get('lot.qt') diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 57a4335..ffab505 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -266,6 +266,112 @@ class PurchaseTradeTestCase(ModuleTestCase): 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( diff --git a/modules/purchase_trade/view/purchase_configuration_form.xml b/modules/purchase_trade/view/purchase_configuration_form.xml index 2d8995d..207c863 100644 --- a/modules/purchase_trade/view/purchase_configuration_form.xml +++ b/modules/purchase_trade/view/purchase_configuration_form.xml @@ -4,5 +4,10 @@