diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py index cb948e7..94618be 100755 --- a/modules/purchase_trade/pricing.py +++ b/modules/purchase_trade/pricing.py @@ -511,13 +511,13 @@ class MtmStrategy(ModelSQL, ModelView): Currency = pool.get('currency.currency') total = Decimal(0) - scenario = self.scenario - dt = scenario.valuation_date - - for comp in self.components: - value = Decimal(0) - - if comp.price_source_type == 'curve' and comp.price_index: + scenario = self.scenario + dt = scenario.valuation_date + + for comp in self.components: + value = Decimal(0) + + if comp.price_source_type == 'curve' and comp.price_index: value = Decimal( comp.price_index.get_price( dt, @@ -526,14 +526,22 @@ class MtmStrategy(ModelSQL, ModelView): last=scenario.use_last_price ) ) - - elif comp.price_source_type == 'matrix' and comp.price_matrix: - value = self._get_matrix_price(comp, line, dt) - - if comp.ratio: - value *= Decimal(comp.ratio) / Decimal(100) - - total += value * qty + + elif comp.price_source_type == 'matrix' and comp.price_matrix: + value = self._get_matrix_price(comp, line, dt) + + elif comp.price_source_type == 'fixed': + value = Decimal(comp.get_price( + dt, + line.unit, + self.currency, + last=scenario.use_last_price + )) + + if comp.ratio: + value *= Decimal(comp.ratio) / Decimal(100) + + total += value * qty return Decimal(str(total)).quantize(Decimal("0.01")) @@ -691,22 +699,37 @@ class Component(ModelSQL, ModelView): required=False, ondelete='CASCADE' ) - price_source_type = fields.Selection([ - ('curve', 'Curve'), - ('matrix', 'Matrix'), - # ('manual', 'Manual'), - ], "Price Source", required=True) - - fix_type = fields.Many2One('price.fixtype',"Fixation type") - ratio = fields.Numeric("%",digits=(16,7)) - price_index = fields.Many2One('price.price',"Curve") - price_matrix = fields.Many2One('price.matrix', "Price Matrix") - currency = fields.Function(fields.Many2One('currency.currency',"Curr."),'get_cur') - auto = fields.Boolean("Auto") - fallback = fields.Boolean("Fallback") - calendar = fields.Many2One('price.calendar',"Calendar") - nbdays = fields.Function(fields.Integer("Nb days"),'get_nbdays') - triggers = fields.One2Many('pricing.trigger','component',"Period rules") + price_source_type = fields.Selection([ + ('curve', 'Curve'), + ('matrix', 'Matrix'), + ('fixed', 'Fixed'), + ], "Price Source", required=True) + + fix_type = fields.Many2One('price.fixtype',"Fixation type") + ratio = fields.Numeric("%",digits=(16,7)) + price_index = fields.Many2One('price.price',"Curve") + price_matrix = fields.Many2One('price.matrix', "Price Matrix") + fixed_price = fields.Numeric( + "Fixed Price", digits=(16, 6), + states={ + 'invisible': Eval('price_source_type') != 'fixed', + 'required': Eval('price_source_type') == 'fixed', + }, + depends=['price_source_type']) + fixed_currency = fields.Many2One('currency.currency', "Fixed Curr.") + currency = fields.Function( + fields.Many2One( + 'currency.currency', "Curr.", + states={ + 'required': Eval('price_source_type') == 'fixed', + }, + depends=['price_source_type']), + 'get_cur', setter='set_cur') + auto = fields.Boolean("Auto") + fallback = fields.Boolean("Fallback") + calendar = fields.Many2One('price.calendar',"Calendar") + nbdays = fields.Function(fields.Integer("Nb days"),'get_nbdays') + triggers = fields.One2Many('pricing.trigger','component',"Period rules") pricing_date = fields.Date("Pricing date max") def get_rec_name(self, name=None): @@ -714,16 +737,35 @@ class Component(ModelSQL, ModelView): return '[' + self.fix_type.name + '] ' + self.price_index.price_index if self.price_matrix: return '[' + self.fix_type.name + '] ' + self.price_matrix.name + if self.price_source_type == 'fixed': + return '[' + self.fix_type.name + '] Fixed' else: return '[' + self.fix_type.name + '] ' - def get_cur(self,name): + def get_cur(self, name=None): if self.price_index: PI = Pool().get('price.price') pi = PI(self.price_index) return pi.price_currency if self.price_matrix: return self.price_matrix.currency + if self.price_source_type == 'fixed': + return self.fixed_currency + + @fields.depends( + 'price_source_type', 'price_index', 'price_matrix', + 'fixed_currency') + def on_change_with_currency(self): + if self.price_source_type == 'curve' and self.price_index: + return self.get_cur() + if self.price_source_type == 'matrix' and self.price_matrix: + return self.get_cur() + if self.price_source_type == 'fixed': + return self.fixed_currency + + @classmethod + def set_cur(cls, components, name, value): + cls.write(components, {'fixed_currency': value}) def get_calendar(self): if self.calendar: @@ -803,6 +845,17 @@ class Component(ModelSQL, ModelView): price_qt *= Decimal(str(rate)) return round(price_qt, 4) + def _convert_fixed_price(self, price, currency): + price_qt = Decimal(str(price or 0)) + price_currency = getattr(self, 'fixed_currency', None) + if price_currency and currency and currency != price_currency: + Currency = Pool().get('currency.currency') + rates = Currency._get_rate([price_currency]) + rate = rates.get(price_currency.id) + if rate: + price_qt *= Decimal(str(rate)) + return round(price_qt, 4) + def get_price(self, price_date, unit, currency, last=False): if self.price_source_type == 'curve' and self.price_index: PI = Pool().get('price.price') @@ -815,6 +868,8 @@ class Component(ModelSQL, ModelView): if line: return self._convert_matrix_price( line.price_value, unit, currency) + if self.price_source_type == 'fixed': + return self._convert_fixed_price(self.fixed_price, currency) return Decimal(0) def get_nbdays(self, name): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index f05a697..6c0e4ea 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -80,6 +80,29 @@ class PurchaseTradeTestCase(ModuleTestCase): strategy.get_mtm(line, Decimal('10')), Decimal('250.00')) + @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_add_physical_lot_defaults_hidden_premium_and_chunk_key(self): 'add physical lot works when hidden tree fields are not loaded' @@ -182,6 +205,28 @@ class PurchaseTradeTestCase(ModuleTestCase): 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() @@ -1941,6 +1986,39 @@ class PurchaseTradeTestCase(ModuleTestCase): 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') diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index a038637..abd0e7b 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -356,6 +356,14 @@ class ValuationBase(ModelSQL): value = Decimal(strategy._get_matrix_price( comp, line, scenario.valuation_date)) + elif comp.price_source_type == 'fixed': + value = Decimal(comp.get_price( + scenario.valuation_date, + line.unit, + strategy.currency, + last=scenario.use_last_price + )) + if comp.ratio: value *= Decimal(comp.ratio) / Decimal(100) diff --git a/modules/purchase_trade/view/component_form.xml b/modules/purchase_trade/view/component_form.xml index a14a2bd..1aa2d78 100755 --- a/modules/purchase_trade/view/component_form.xml +++ b/modules/purchase_trade/view/component_form.xml @@ -1,16 +1,21 @@ -
-