diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index 995673a..8c2f420 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -210,9 +210,10 @@ def register(): purchase.ContractDocumentType, purchase.DocTemplate, purchase.DocTypeTemplate, - purchase.PurchaseStrategy, - purchase.PriceComposition, - purchase.QualityAnalysis, + purchase.PurchaseStrategy, + purchase.PriceComposition, + purchase.PremiumComposition, + purchase.QualityAnalysis, purchase.Assay, purchase.AssayLine, purchase.AssayElement, @@ -283,9 +284,10 @@ def register(): sale.SaleStrategy, sale.OpenPosition, sale.Backtoback, - sale.AnalyticDimensionAssignment, - sale.PriceComposition, - module='sale', type_='model') + sale.AnalyticDimensionAssignment, + sale.PriceComposition, + sale.PremiumComposition, + module='sale', type_='model') Pool.register( lot.LotShipping, lot.LotMatching, diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py index dc082a6..93d5e4c 100755 --- a/modules/purchase_trade/pricing.py +++ b/modules/purchase_trade/pricing.py @@ -1239,9 +1239,9 @@ class Trigger(ModelSQL,ModelView): states={ 'readonly': Eval('pricing_period') != None, }) - average = fields.Boolean("Avg") - last = fields.Boolean("Last") - application_period = fields.Many2One('pricing.period',"Application period") + average = fields.Boolean("Avg") + last = fields.Boolean("Latest") + application_period = fields.Many2One('pricing.period',"Application period") from_a = fields.Date("From", states={ 'readonly': Eval('application_period') != None, @@ -1249,8 +1249,12 @@ class Trigger(ModelSQL,ModelView): to_a = fields.Date("To", states={ 'readonly': Eval('application_period') != None, - }) - + }) + + @classmethod + def default_average(cls): + return True + @fields.depends('pricing_period') def on_change_with_application_period(self): if not self.application_period and self.pricing_period: diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index df0e6c6..783ce76 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -733,13 +733,57 @@ class Purchase(metaclass=PoolMeta): logger.info("EFP_PRICE:%s",line.unit_price) Line.save([line]) -class PriceComposition(ModelSQL,ModelView): - "Price Composition" - __name__ = 'price.composition' +class PriceComposition(ModelSQL,ModelView): + "Price Composition" + __name__ = 'price.composition' line = fields.Many2One('purchase.line',"Purchase line") - component = fields.Char("Component") - price = fields.Numeric("Price") + component = fields.Char("Component") + price = fields.Numeric("Price") + +class PremiumComposition(ModelSQL, ModelView): + "Premium Composition" + __name__ = 'premium.composition' + + line = fields.Many2One('purchase.line', "Purchase line", ondelete='CASCADE') + type = fields.Many2One('price.fixtype', "Type") + premium = fields.Numeric("Premium/Discount", digits=(16, 6)) + currency = fields.Many2One('currency.currency', "Curr.") + reference = fields.Text("Reference") + base_amount = fields.Function( + fields.Numeric("Base Amt", digits=(16, 6)), 'get_base_amount') + + def _get_owner_line(self): + return getattr(self, 'line', None) or getattr(self, 'sale_line', None) + + def _get_contract(self): + line = self._get_owner_line() + if line: + return getattr(line, 'purchase', None) or getattr(line, 'sale', None) + + def _get_contract_currency(self): + contract = self._get_contract() + return getattr(contract, 'currency', None) + + def _get_contract_date(self): + contract = self._get_contract() + return ( + getattr(contract, 'purchase_date', None) + or getattr(contract, 'sale_date', None)) + + def get_base_amount(self, name=None): + amount = Decimal(str(self.premium or 0)) + currency = getattr(self, 'currency', None) + contract_currency = self._get_contract_currency() + if not currency or not contract_currency or currency == contract_currency: + return round(amount, 6) + Currency = Pool().get('currency.currency') + contract_date = self._get_contract_date() + if contract_date: + with Transaction().set_context(date=contract_date): + return round(Currency.compute( + currency, amount, contract_currency), 6) + return round(Currency.compute(currency, amount, contract_currency), 6) class AssayImporter: @@ -1362,7 +1406,9 @@ class Line(metaclass=PoolMeta): concentration = fields.Numeric("Concentration") price_components = fields.One2Many('pricing.component','line',"Components") price_pricing = fields.One2Many('pricing.pricing','line',"Pricing") - price_summary = fields.One2Many('purchase.pricing.summary','line',"Summary") + price_summary = fields.One2Many('purchase.pricing.summary','line',"Summary") + premium_decomposition = fields.One2Many( + 'premium.composition', 'line', "Premium decomposition") estimated_date = fields.One2Many('pricing.estimated','line',"Estimated date") optional = fields.One2Many('optional.scenario','line',"Optionals Scenarios") lots = fields.One2Many('lot.lot','line',"Lots",readonly=True) @@ -1412,7 +1458,9 @@ class Line(metaclass=PoolMeta): 'invisible': (~Eval('enable_linked_currency')), 'required': Eval('enable_linked_currency'), }, depends=['enable_linked_currency']) - premium = fields.Numeric("Premium/Discount",digits='unit') + premium = fields.Function( + fields.Numeric("Premium/Discount",digits='unit'), + 'get_premium', setter='set_premium') fee_ = fields.Many2One('fee.fee',"Fee") pricing_rule = fields.Text("Pricing description") @@ -1671,13 +1719,32 @@ class Line(metaclass=PoolMeta): return price return round(price / factor, 4) + def get_premium(self, name=None): + return round(sum( + Decimal(str(getattr(premium, 'base_amount', None) + if getattr(premium, 'base_amount', None) is not None + else premium.get_base_amount('base_amount') or 0)) + for premium in (self.premium_decomposition or [])), 4) + + @classmethod + def set_premium(cls, lines, name, value): + Premium = Pool().get('premium.composition') + value = Decimal(str(value or 0)) + for line in lines: + existing = Premium.search([('line', '=', line.id)]) + if existing: + Premium.delete(existing) + if value: + Premium.create([{ + 'line': line.id, + 'premium': value, + 'currency': ( + line.purchase.currency.id + if line.purchase and line.purchase.currency else None), + }]) + def _get_premium_price(self): - premium = Decimal(self.premium or 0) - if not premium: - return Decimal(0) - if self.enable_linked_currency and self.linked_currency: - return self._linked_to_line_price(premium) - return premium + return Decimal(self.premium or 0) def _get_amount_quantity(self): if getattr(self, 'finished', False): @@ -1760,7 +1827,7 @@ class Line(metaclass=PoolMeta): + Decimal(lot_premium or 0)), 4) - @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') + @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit','premium_decomposition') def on_change_with_unit_price(self, name=None): Date = Pool().get('ir.date') logger.info("ONCHANGEUNITPRICE:%s",self.unit_price) @@ -1781,7 +1848,8 @@ class Line(metaclass=PoolMeta): 'type', 'quantity', 'quantity_theorical', 'finished', 'unit_price', 'unit', 'product', 'lots', 'purchase', '_parent_purchase.currency', '_parent_purchase.wb', - 'premium', 'enable_linked_currency', 'linked_currency', 'linked_unit') + 'premium_decomposition', 'enable_linked_currency', + 'linked_currency', 'linked_unit') def on_change_with_amount(self): if self.type == 'line' and self.unit_price is not None: quantity = self._get_amount_quantity() @@ -1797,7 +1865,8 @@ class Line(metaclass=PoolMeta): @fields.depends( 'unit', 'product', 'price_type', 'enable_linked_currency', - 'linked_currency', 'linked_unit', 'linked_price', 'premium', + 'linked_currency', 'linked_unit', 'linked_price', + 'premium_decomposition', methods=['on_change_with_unit_price', 'on_change_with_amount']) def _recompute_trade_price_fields(self): self.unit_price = self.on_change_with_unit_price() @@ -1807,6 +1876,10 @@ class Line(metaclass=PoolMeta): def on_change_premium(self): self._recompute_trade_price_fields() + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_premium_decomposition(self): + self._recompute_trade_price_fields() + @fields.depends(methods=['_recompute_trade_price_fields']) def on_change_price_type(self): self._recompute_trade_price_fields() diff --git a/modules/purchase_trade/purchase.xml b/modules/purchase_trade/purchase.xml index 54943e3..180a4b4 100755 --- a/modules/purchase_trade/purchase.xml +++ b/modules/purchase_trade/purchase.xml @@ -156,13 +156,23 @@ this repository contains the full copyright notices and license terms. --> mtm_tree - - price.composition - tree - price_composition_tree - - - + + price.composition + tree + price_composition_tree + + + premium.composition + tree + premium_composition_tree + + + premium.composition + form + premium_composition_form + + + quality.analysis tree quality_analysis_tree diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 155e886..bb5b64d 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -1249,6 +1249,11 @@ class PriceComposition(metaclass=PoolMeta): __name__ = 'price.composition' sale_line = fields.Many2One('sale.line',"Sale line") + +class PremiumComposition(metaclass=PoolMeta): + __name__ = 'premium.composition' + + sale_line = fields.Many2One('sale.line', "Sale line", ondelete='CASCADE') class SaleLine(metaclass=PoolMeta): __name__ = 'sale.line' @@ -1421,7 +1426,6 @@ class SaleLine(metaclass=PoolMeta): lots = fields.One2Many('lot.lot','sale_line',"Lots",readonly=True) fees = fields.One2Many('fee.fee', 'sale_line', 'Fees') quantity_theorical = fields.Numeric("Th. quantity", digits='unit', readonly=False) - premium = fields.Numeric("Premium/Discount",digits='unit') price_type = fields.Selection([ ('cash', 'Cash Price'), ('priced', 'Priced'), @@ -1449,6 +1453,8 @@ class SaleLine(metaclass=PoolMeta): derivatives = fields.One2Many('derivative.derivative','sale_line',"Derivatives") price_pricing = fields.One2Many('pricing.pricing','sale_line',"Pricing") price_summary = fields.One2Many('sale.pricing.summary','sale_line',"Summary") + premium_decomposition = fields.One2Many( + 'premium.composition', 'sale_line', "Premium decomposition") estimated_date = fields.One2Many('pricing.estimated','sale_line',"Estimated date") tol_min = fields.Numeric("Tol - in %",states={ 'readonly': (Eval('inherit_tol')), @@ -1492,7 +1498,9 @@ class SaleLine(metaclass=PoolMeta): 'invisible': (~Eval('enable_linked_currency')), 'required': Eval('enable_linked_currency'), }, depends=['enable_linked_currency']) - premium = fields.Numeric("Premium/Discount",digits='unit') + premium = fields.Function( + fields.Numeric("Premium/Discount",digits='unit'), + 'get_premium', setter='set_premium') fee_ = fields.Many2One('fee.fee',"Fee") attributes = fields.Dict( @@ -1760,13 +1768,32 @@ class SaleLine(metaclass=PoolMeta): return price return round(price / factor, 4) + def get_premium(self, name=None): + return round(sum( + Decimal(str(getattr(premium, 'base_amount', None) + if getattr(premium, 'base_amount', None) is not None + else premium.get_base_amount('base_amount') or 0)) + for premium in (self.premium_decomposition or [])), 4) + + @classmethod + def set_premium(cls, lines, name, value): + Premium = Pool().get('premium.composition') + value = Decimal(str(value or 0)) + for line in lines: + existing = Premium.search([('sale_line', '=', line.id)]) + if existing: + Premium.delete(existing) + if value: + Premium.create([{ + 'sale_line': line.id, + 'premium': value, + 'currency': ( + line.sale.currency.id + if line.sale and line.sale.currency else None), + }]) + def _get_premium_price(self): - premium = Decimal(self.premium or 0) - if not premium: - return Decimal(0) - if self.enable_linked_currency and self.linked_currency: - return self._linked_to_line_price(premium) - return premium + return Decimal(self.premium or 0) def _get_amount_quantity(self): if getattr(self, 'finished', False): @@ -1852,7 +1879,7 @@ class SaleLine(metaclass=PoolMeta): unit_price = self.unit_price return unit_price - @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit') + @fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit','premium_decomposition') def on_change_with_unit_price(self, name=None): Date = Pool().get('ir.date') logger.info("ONCHANGEUNITPRICE:%s",self.unit_price) @@ -1872,7 +1899,8 @@ class SaleLine(metaclass=PoolMeta): 'type', 'quantity', 'quantity_theorical', 'finished', 'unit_price', 'unit', 'product', 'lots', 'sale', '_parent_sale.currency', '_parent_sale.wb', - 'premium', 'enable_linked_currency', 'linked_currency', 'linked_unit') + 'premium_decomposition', 'enable_linked_currency', + 'linked_currency', 'linked_unit') def on_change_with_amount(self): if self.type == 'line': currency = self.sale.currency if self.sale else None @@ -1885,7 +1913,8 @@ class SaleLine(metaclass=PoolMeta): @fields.depends( 'unit', 'product', 'price_type', 'enable_linked_currency', - 'linked_currency', 'linked_unit', 'linked_price', 'premium', + 'linked_currency', 'linked_unit', 'linked_price', + 'premium_decomposition', methods=['on_change_with_unit_price', 'on_change_with_amount']) def _recompute_trade_price_fields(self): self.unit_price = self.on_change_with_unit_price() @@ -1895,6 +1924,10 @@ class SaleLine(metaclass=PoolMeta): def on_change_premium(self): self._recompute_trade_price_fields() + @fields.depends(methods=['_recompute_trade_price_fields']) + def on_change_premium_decomposition(self): + self._recompute_trade_price_fields() + @fields.depends(methods=['_recompute_trade_price_fields']) def on_change_price_type(self): self._recompute_trade_price_fields() diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 6c0e4ea..55c3342 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -1907,6 +1907,13 @@ class PurchaseTradeTestCase(ModuleTestCase): 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_label_is_latest(self): + 'pricing trigger defaults to Avg and labels last as Latest' + Trigger = Pool().get('pricing.trigger') + + self.assertTrue(Trigger.default_average()) + self.assertEqual(Trigger.last.string, 'Latest') + 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') @@ -2041,6 +2048,37 @@ class PurchaseTradeTestCase(ModuleTestCase): 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_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) diff --git a/modules/purchase_trade/view/premium_composition_form.xml b/modules/purchase_trade/view/premium_composition_form.xml new file mode 100644 index 0000000..a4d98ec --- /dev/null +++ b/modules/purchase_trade/view/premium_composition_form.xml @@ -0,0 +1,15 @@ +
+