Premium composition

This commit is contained in:
2026-05-31 20:50:23 +02:00
parent 38c614af98
commit 735c5431af
10 changed files with 239 additions and 51 deletions

View File

@@ -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,

View File

@@ -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:

View File

@@ -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()

View File

@@ -156,13 +156,23 @@ this repository contains the full copyright notices and license terms. -->
<field name="name">mtm_tree</field>
</record>
<record model="ir.ui.view" id="price_composition_view_tree">
<field name="model">price.composition</field>
<field name="type">tree</field>
<field name="name">price_composition_tree</field>
</record>
<record model="ir.ui.view" id="quality_analysis_view_tree">
<record model="ir.ui.view" id="price_composition_view_tree">
<field name="model">price.composition</field>
<field name="type">tree</field>
<field name="name">price_composition_tree</field>
</record>
<record model="ir.ui.view" id="premium_composition_view_tree">
<field name="model">premium.composition</field>
<field name="type">tree</field>
<field name="name">premium_composition_tree</field>
</record>
<record model="ir.ui.view" id="premium_composition_view_form">
<field name="model">premium.composition</field>
<field name="type">form</field>
<field name="name">premium_composition_form</field>
</record>
<record model="ir.ui.view" id="quality_analysis_view_tree">
<field name="model">quality.analysis</field>
<field name="type">tree</field>
<field name="name">quality_analysis_tree</field>

View File

@@ -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()

View File

@@ -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)

View File

@@ -0,0 +1,15 @@
<form col="4">
<label name="type"/>
<field name="type"/>
<newline/>
<label name="premium"/>
<field name="premium"/>
<label name="currency"/>
<field name="currency"/>
<newline/>
<label name="base_amount"/>
<field name="base_amount"/>
<newline/>
<label name="reference"/>
<field name="reference" colspan="3"/>
</form>

View File

@@ -0,0 +1,7 @@
<tree editable="1">
<field name="type"/>
<field name="premium"/>
<field name="currency"/>
<field name="reference"/>
<field name="base_amount"/>
</tree>

View File

@@ -90,9 +90,12 @@ this repository contains the full copyright notices and license terms. -->
<button name="apply_default_fees" icon="tryton-launch"/>
<field name="fees" colspan="4" mode="tree,mode" view_ids="purchase_trade.fee_view_tree_sequence,purchase_trade.fee_view_form"/>
</page>
<page string="Mtm" col="4" id="mtm">
<field name="mtm" colspan="4"/>
</page>
<page string="Premium composition" col="4" id="premium_composition">
<field name="premium_decomposition" colspan="4" mode="tree,form" view_ids="purchase_trade.premium_composition_view_tree,purchase_trade.premium_composition_view_form"/>
</page>
<page string="Mtm" col="4" id="mtm">
<field name="mtm" colspan="4"/>
</page>
<page string="Derivatives" col="4" id="der">
<field name="derivatives" colspan="4"/>
</page>

View File

@@ -75,9 +75,12 @@ this repository contains the full copyright notices and license terms. -->
<button name="apply_default_fees" icon="tryton-launch"/>
<field name="fees" colspan="4" view_ids="purchase_trade.fee_view_tree_sequence"/>
</page>
<page string="Mtm" col="4" id="mtm">
<field name="mtm" colspan="4"/>
</page>
<page string="Premium composition" col="4" id="premium_composition">
<field name="premium_decomposition" colspan="4" mode="tree,form" view_ids="purchase_trade.premium_composition_view_tree,purchase_trade.premium_composition_view_form"/>
</page>
<page string="Mtm" col="4" id="mtm">
<field name="mtm" colspan="4"/>
</page>
<page string="Derivatives" col="4" id="der">
<field name="derivatives" colspan="4"/>
</page>