diff --git a/modules/purchase/purchase.xml b/modules/purchase/purchase.xml
index d83cd92..cc749f2 100755
Binary files a/modules/purchase/purchase.xml and b/modules/purchase/purchase.xml differ
diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index d46906b..9f0e81b 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -96,6 +96,12 @@ def register():
pricing.ImportPricesResult,
duplicate.TradeCustomDuplicateStart,
module='purchase_trade', type_='model')
+ Pool.register(
+ configuration.PurchaseConfiguration,
+ module='purchase', type_='model')
+ Pool.register(
+ configuration.SaleConfiguration,
+ module='sale', type_='model')
Pool.register(
incoming.ImportSwift,
incoming.PrepareDocuments,
diff --git a/modules/purchase_trade/company_defaults.py b/modules/purchase_trade/company_defaults.py
index 3d8f6d5..5a3283d 100644
--- a/modules/purchase_trade/company_defaults.py
+++ b/modules/purchase_trade/company_defaults.py
@@ -41,6 +41,23 @@ def default_itsa_currency():
return record_id(currency) if currency else None
+def default_itsa_price_fix_type():
+ fix_type = _search_first('price.fixtype', [
+ [('name', '=', 'Market Price')],
+ [('name', '=', 'Market price')],
+ [('name', 'ilike', 'Market Price')],
+ ])
+ return record_id(fix_type) if fix_type else None
+
+
+def default_itsa_price_calendar():
+ calendar = _search_first('price.calendar', [
+ [('name', '=', 'Argus Sulphuric Acid')],
+ [('name', 'ilike', 'Argus Sulphuric Acid')],
+ ])
+ return record_id(calendar) if calendar else None
+
+
def default_itsa_unit():
unit = _search_first('product.uom', [
[('symbol', '=', 'Mt')],
diff --git a/modules/purchase_trade/configuration.py b/modules/purchase_trade/configuration.py
index 6a574d4..f664668 100644
--- a/modules/purchase_trade/configuration.py
+++ b/modules/purchase_trade/configuration.py
@@ -4,6 +4,28 @@ from trytond.pyson import Eval
from trytond.transaction import Transaction
+class PurchaseConfiguration(metaclass=PoolMeta):
+ __name__ = 'purchase.configuration'
+
+ allow_modification_after_validation = fields.Boolean(
+ "Autorise modification after validation")
+
+ @classmethod
+ def default_allow_modification_after_validation(cls):
+ return False
+
+
+class SaleConfiguration(metaclass=PoolMeta):
+ __name__ = 'sale.configuration'
+
+ allow_modification_after_validation = fields.Boolean(
+ "Autorise modification after validation")
+
+ @classmethod
+ def default_allow_modification_after_validation(cls):
+ return False
+
+
class AccountConfiguration(metaclass=PoolMeta):
__name__ = 'account.configuration'
diff --git a/modules/purchase_trade/configuration.xml b/modules/purchase_trade/configuration.xml
index edcccc9..8946caa 100644
--- a/modules/purchase_trade/configuration.xml
+++ b/modules/purchase_trade/configuration.xml
@@ -10,6 +10,16 @@
form
template_configuration_form
+
+ purchase.configuration
+
+ purchase_configuration_form
+
+
+ sale.configuration
+
+ sale_configuration_form
+
Pricing Configuration
diff --git a/modules/purchase_trade/ctrm_reporting.py b/modules/purchase_trade/ctrm_reporting.py
index 95e8518..1865bec 100644
--- a/modules/purchase_trade/ctrm_reporting.py
+++ b/modules/purchase_trade/ctrm_reporting.py
@@ -21,7 +21,7 @@ PHYSICAL_VALUATION_TYPES = [
'market',
]
DERIVATIVE_VALUATION_TYPES = ['derivative']
-MTM_VALUATION_TYPES = ['mtm']
+MTM_VALUATION_TYPES = ['mtm', 'pur. mtm', 'sale mtm']
ALL_VALUATION_TYPES = (
PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES
+ MTM_VALUATION_TYPES)
@@ -496,6 +496,8 @@ class CTRMMtmPnl(ModelSQL, ModelView):
('shipment fee', 'Shipment fee'),
('market', 'Market'),
('mtm', 'Mtm'),
+ ('pur. mtm', 'Pur. Mtm'),
+ ('sale mtm', 'Sale Mtm'),
('derivative', 'Derivative'),
], "Type")
reference = fields.Char("Reference")
diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py
index 486a972..77c8b3b 100755
--- a/modules/purchase_trade/pricing.py
+++ b/modules/purchase_trade/pricing.py
@@ -25,7 +25,8 @@ from io import BytesIO
from xml.etree import ElementTree
from trytond.modules.purchase_trade.purchase import (TRIGGERS)
from trytond.modules.purchase_trade.company_defaults import (
- default_itsa_currency, default_itsa_unit, is_itsa_company)
+ default_itsa_currency, default_itsa_price_calendar,
+ default_itsa_price_fix_type, default_itsa_unit, is_itsa_company)
logger = logging.getLogger(__name__)
@@ -594,6 +595,13 @@ class MtmScenario(ModelSQL, ModelView):
def default_valuation_date_type():
return 'fixed'
+ @classmethod
+ def default_calendar(cls):
+ if is_itsa_company():
+ calendar = default_itsa_price_calendar()
+ if calendar:
+ return calendar
+
def get_resolved_valuation_date(self, name=None):
return self.get_valuation_date()
@@ -777,12 +785,14 @@ class Mtm(ModelSQL, ModelView):
currency = fields.Many2One('currency.currency', "Currency")
- def get_cur(self, name=None):
- if self.price_index:
- return self.price_index.price_currency
- if self.price_matrix:
- return self.price_matrix.currency
- return None
+ def get_cur(self, name=None):
+ if self.price_index:
+ return self.price_index.price_currency
+ if self.price_matrix:
+ return self.price_matrix.currency
+ if self.currency:
+ return self.currency
+ return None
@fields.depends('price_index','price_matrix')
def on_change_with_currency(self):
@@ -795,6 +805,18 @@ class Mtm(ModelSQL, ModelView):
if currency:
return currency
+ @classmethod
+ def default_fix_type(cls):
+ if is_itsa_company():
+ fix_type = default_itsa_price_fix_type()
+ if fix_type:
+ return fix_type
+
+ @classmethod
+ def default_ratio(cls):
+ if is_itsa_company():
+ return Decimal('100')
+
class PriceMatrix(ModelSQL, ModelView):
"Price Matrix"
__name__ = 'price.matrix'
@@ -934,26 +956,24 @@ class Component(ModelSQL, ModelView):
else:
return '[' + self.fix_type.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 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
+ 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
+ return self.fixed_currency
@classmethod
def default_fixed_currency(cls):
@@ -961,6 +981,25 @@ class Component(ModelSQL, ModelView):
currency = default_itsa_currency()
if currency:
return currency
+
+ @classmethod
+ def default_fix_type(cls):
+ if is_itsa_company():
+ fix_type = default_itsa_price_fix_type()
+ if fix_type:
+ return fix_type
+
+ @classmethod
+ def default_ratio(cls):
+ if is_itsa_company():
+ return Decimal('100')
+
+ @classmethod
+ def default_calendar(cls):
+ if is_itsa_company():
+ calendar = default_itsa_price_calendar()
+ if calendar:
+ return calendar
@classmethod
def set_cur(cls, components, name, value):
diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py
index 3a130a2..f9d05da 100755
--- a/modules/purchase_trade/purchase.py
+++ b/modules/purchase_trade/purchase.py
@@ -272,6 +272,10 @@ class Purchase(metaclass=PoolMeta):
def __setup__(cls):
super().__setup__()
cls._transitions.discard(('confirmed', 'processing'))
+ cls.lines.states['readonly'] = (
+ (Eval('state') != 'draft')
+ & ((Eval('state') != 'quotation')
+ | ~Eval('allow_modification_after_validation')))
cls._buttons['process'] = {
'invisible': True,
'depends': ['state'],
@@ -380,11 +384,14 @@ class Purchase(metaclass=PoolMeta):
doc_template = fields.Many2One('doc.template',"Template")
required_documents = fields.Many2Many(
'contract.document.type', 'purchase', 'doc_type', 'Required Documents')
- analytic_dimensions = fields.One2Many(
- 'analytic.dimension.assignment',
- 'purchase',
- 'Analytic Dimensions'
- )
+ analytic_dimensions = fields.One2Many(
+ 'analytic.dimension.assignment',
+ 'purchase',
+ 'Analytic Dimensions'
+ )
+ allow_modification_after_validation = fields.Function(
+ fields.Boolean("Autorise modification after validation"),
+ 'on_change_with_allow_modification_after_validation')
trader = fields.Many2One(
'party.party', "Trader",
domain=[('categories.name', '=', 'TRADER')])
@@ -404,6 +411,13 @@ class Purchase(metaclass=PoolMeta):
self.company and self.company.party
and self.company.party.name in {'MELYA', 'ITSA'})
+ def on_change_with_allow_modification_after_validation(self, name=None):
+ Configuration = Pool().get('purchase.configuration')
+ configurations = Configuration.search([], limit=1)
+ return bool(
+ configurations
+ and configurations[0].allow_modification_after_validation)
+
def _get_default_bank_account(self):
if not self.party or not self.party.bank_accounts:
return None
@@ -2087,15 +2101,97 @@ class Line(metaclass=PoolMeta):
raise UserError(
"Shipment period From date must be before To date.")
+ @classmethod
+ def _estimated_bl_relation_field(cls):
+ return 'line'
+
+ @classmethod
+ def _has_estimated_bl_date(cls, estimated_dates):
+ return any(
+ getattr(estimated, 'trigger', None) == 'bldate'
+ for estimated in (estimated_dates or []))
+
+ @classmethod
+ def _values_have_estimated_bl_date(cls, values):
+ for command in values.get('estimated_date') or []:
+ action = command[0]
+ if action == 'create':
+ if any(
+ estimated.get('trigger') == 'bldate'
+ for estimated in command[1]):
+ return True
+ elif action == 'write':
+ actions = iter(command[1:])
+ for _estimated_ids, estimated_values in zip(actions, actions):
+ if estimated_values.get('trigger') == 'bldate':
+ return True
+ return False
+
+ @classmethod
+ def _delivery_period_from_values(cls, values):
+ period = values.get('del_period')
+ if period and not hasattr(period, 'beg_date'):
+ period = Pool().get('product.month')(period)
+ return period
+
+ @classmethod
+ def _default_estimated_bl_date(cls, values):
+ period = cls._delivery_period_from_values(values)
+ return getattr(period, 'beg_date', None)
+
+ @classmethod
+ def _set_default_estimated_bl_date_values(cls, values):
+ if cls._values_have_estimated_bl_date(values):
+ return
+ estimated_date = cls._default_estimated_bl_date(values)
+ if not estimated_date:
+ return
+ if values.get('estimated_date') is None:
+ values['estimated_date'] = []
+ values['estimated_date'].append(('create', [{
+ 'trigger': 'bldate',
+ 'estimated_date': estimated_date,
+ }]))
+
+ @classmethod
+ def _create_missing_estimated_bl_dates(cls, lines):
+ Estimated = Pool().get('pricing.estimated')
+ values = []
+ relation_field = cls._estimated_bl_relation_field()
+ for line in lines:
+ if cls._has_estimated_bl_date(getattr(line, 'estimated_date', None)):
+ continue
+ del_period = getattr(line, 'del_period', None)
+ estimated_date = getattr(del_period, 'beg_date', None)
+ if not estimated_date:
+ continue
+ values.append({
+ relation_field: line.id,
+ 'trigger': 'bldate',
+ 'estimated_date': estimated_date,
+ })
+ if values:
+ Estimated.create(values)
+
@classmethod
def create(cls, vlist):
+ regenerate_valuation = any(
+ cls._should_regenerate_valuation(values) for values in vlist)
for values in vlist:
cls._check_delivery_period_values([cls()], values)
+ cls._set_default_estimated_bl_date_values(values)
cls._set_initial_quantity_values(values)
lines = super().create(vlist)
if not Transaction().context.get('_purchase_trade_skip_fee_rules'):
Pool().get('fee.rule').apply_to_lines(
lines, 'purchase_line', auto_only=True)
+ if (regenerate_valuation
+ and not Transaction().context.get(
+ '_purchase_trade_skip_valuation_regeneration')):
+ Valuation = Pool().get('valuation.valuation')
+ with Transaction().set_context(
+ _purchase_trade_skip_valuation_regeneration=True):
+ Valuation.regenerate_for_purchase_lines(lines)
return lines
@classmethod
@@ -2689,12 +2785,28 @@ class Line(metaclass=PoolMeta):
def on_change_linked_unit(self):
self._recompute_trade_price_fields()
+ @classmethod
+ def _valuation_regeneration_fields(cls):
+ return {
+ 'quantity', 'quantity_theorical', 'unit', 'unit_price',
+ 'price_type', 'premium', 'linked_price', 'linked_currency',
+ 'linked_unit', 'price_pricing', 'price_components', 'derivatives',
+ 'fees', 'lots', 'product', 'currency',
+ }
+
+ @classmethod
+ def _should_regenerate_valuation(cls, values):
+ return bool(cls._valuation_regeneration_fields() & set(values))
+
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
+ valuation_line_ids = set()
for records, values in zip(actions, actions):
cls._check_delivery_period_values(records, values)
+ if cls._should_regenerate_valuation(values):
+ valuation_line_ids.update(record.id for record in records)
args.extend((records, values))
# Agents:
@@ -2748,9 +2860,18 @@ class Line(metaclass=PoolMeta):
Pool().get('lot.lot').assert_lines_quantity_consistency(
cls._fresh_lines_for_quantity_consistency(lines))
+ cls._create_missing_estimated_bl_dates(lines)
if not Transaction().context.get('_purchase_trade_skip_fee_rules'):
Pool().get('fee.rule').apply_to_lines(
lines, 'purchase_line', auto_only=True)
+ if (valuation_line_ids
+ and not Transaction().context.get(
+ '_purchase_trade_skip_valuation_regeneration')):
+ Valuation = Pool().get('valuation.valuation')
+ with Transaction().set_context(
+ _purchase_trade_skip_valuation_regeneration=True):
+ Valuation.regenerate_for_purchase_lines(
+ cls.browse(list(valuation_line_ids)))
@classmethod
def _sync_open_lot_quantity(cls, line, vlot, target_quantity):
diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py
index ae3e2b8..d0c7c60 100755
--- a/modules/purchase_trade/sale.py
+++ b/modules/purchase_trade/sale.py
@@ -237,6 +237,10 @@ class Sale(metaclass=PoolMeta):
def __setup__(cls):
super().__setup__()
cls._transitions.discard(('confirmed', 'processing'))
+ cls.lines.states['readonly'] = (
+ (Eval('state') != 'draft')
+ & ((Eval('state') != 'quotation')
+ | ~Eval('allow_modification_after_validation')))
cls._buttons['process'] = {
'invisible': True,
'depends': ['state'],
@@ -329,6 +333,9 @@ class Sale(metaclass=PoolMeta):
'sale',
'Analytic Dimensions'
)
+ allow_modification_after_validation = fields.Function(
+ fields.Boolean("Autorise modification after validation"),
+ 'on_change_with_allow_modification_after_validation')
trader = fields.Many2One(
'party.party', "Trader",
domain=[('categories.name', '=', 'TRADER')])
@@ -348,6 +355,13 @@ class Sale(metaclass=PoolMeta):
self.company and self.company.party
and self.company.party.name in {'MELYA', 'ITSA'})
+ def on_change_with_allow_modification_after_validation(self, name=None):
+ Configuration = Pool().get('sale.configuration')
+ configurations = Configuration.search([], limit=1)
+ return bool(
+ configurations
+ and configurations[0].allow_modification_after_validation)
+
def _get_default_bank_account(self):
if not self.party or not self.party.bank_accounts:
return None
@@ -1911,15 +1925,97 @@ class SaleLine(metaclass=PoolMeta):
raise UserError(
"Shipment period From date must be before To date.")
+ @classmethod
+ def _estimated_bl_relation_field(cls):
+ return 'sale_line'
+
+ @classmethod
+ def _has_estimated_bl_date(cls, estimated_dates):
+ return any(
+ getattr(estimated, 'trigger', None) == 'bldate'
+ for estimated in (estimated_dates or []))
+
+ @classmethod
+ def _values_have_estimated_bl_date(cls, values):
+ for command in values.get('estimated_date') or []:
+ action = command[0]
+ if action == 'create':
+ if any(
+ estimated.get('trigger') == 'bldate'
+ for estimated in command[1]):
+ return True
+ elif action == 'write':
+ actions = iter(command[1:])
+ for _estimated_ids, estimated_values in zip(actions, actions):
+ if estimated_values.get('trigger') == 'bldate':
+ return True
+ return False
+
+ @classmethod
+ def _delivery_period_from_values(cls, values):
+ period = values.get('del_period')
+ if period and not hasattr(period, 'beg_date'):
+ period = Pool().get('product.month')(period)
+ return period
+
+ @classmethod
+ def _default_estimated_bl_date(cls, values):
+ period = cls._delivery_period_from_values(values)
+ return getattr(period, 'beg_date', None)
+
+ @classmethod
+ def _set_default_estimated_bl_date_values(cls, values):
+ if cls._values_have_estimated_bl_date(values):
+ return
+ estimated_date = cls._default_estimated_bl_date(values)
+ if not estimated_date:
+ return
+ if values.get('estimated_date') is None:
+ values['estimated_date'] = []
+ values['estimated_date'].append(('create', [{
+ 'trigger': 'bldate',
+ 'estimated_date': estimated_date,
+ }]))
+
+ @classmethod
+ def _create_missing_estimated_bl_dates(cls, lines):
+ Estimated = Pool().get('pricing.estimated')
+ values = []
+ relation_field = cls._estimated_bl_relation_field()
+ for line in lines:
+ if cls._has_estimated_bl_date(getattr(line, 'estimated_date', None)):
+ continue
+ del_period = getattr(line, 'del_period', None)
+ estimated_date = getattr(del_period, 'beg_date', None)
+ if not estimated_date:
+ continue
+ values.append({
+ relation_field: line.id,
+ 'trigger': 'bldate',
+ 'estimated_date': estimated_date,
+ })
+ if values:
+ Estimated.create(values)
+
@classmethod
def create(cls, vlist):
+ regenerate_valuation = any(
+ cls._should_regenerate_valuation(values) for values in vlist)
for values in vlist:
cls._check_delivery_period_values([cls()], values)
+ cls._set_default_estimated_bl_date_values(values)
cls._set_initial_quantity_values(values)
lines = super().create(vlist)
if not Transaction().context.get('_purchase_trade_skip_fee_rules'):
Pool().get('fee.rule').apply_to_lines(
lines, 'sale_line', auto_only=True)
+ if (regenerate_valuation
+ and not Transaction().context.get(
+ '_purchase_trade_skip_valuation_regeneration')):
+ Valuation = Pool().get('valuation.valuation')
+ with Transaction().set_context(
+ _purchase_trade_skip_valuation_regeneration=True):
+ Valuation.regenerate_for_sale_lines(lines)
return lines
@classmethod
@@ -2631,12 +2727,28 @@ class SaleLine(metaclass=PoolMeta):
Pricing.save([p])
index += 1
+ @classmethod
+ def _valuation_regeneration_fields(cls):
+ return {
+ 'quantity', 'quantity_theorical', 'unit', 'unit_price',
+ 'price_type', 'premium', 'linked_price', 'linked_currency',
+ 'linked_unit', 'price_pricing', 'price_components', 'derivatives',
+ 'fees', 'lots', 'product', 'currency',
+ }
+
+ @classmethod
+ def _should_regenerate_valuation(cls, values):
+ return bool(cls._valuation_regeneration_fields() & set(values))
+
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
+ valuation_line_ids = set()
for records, values in zip(actions, actions):
cls._check_delivery_period_values(records, values)
+ if cls._should_regenerate_valuation(values):
+ valuation_line_ids.update(record.id for record in records)
args.extend((records, values))
old_values = {}
@@ -2684,9 +2796,18 @@ class SaleLine(metaclass=PoolMeta):
Pool().get('lot.lot').assert_lines_quantity_consistency(
cls._fresh_lines_for_quantity_consistency(lines))
+ cls._create_missing_estimated_bl_dates(lines)
if not Transaction().context.get('_purchase_trade_skip_fee_rules'):
Pool().get('fee.rule').apply_to_lines(
lines, 'sale_line', auto_only=True)
+ if (valuation_line_ids
+ and not Transaction().context.get(
+ '_purchase_trade_skip_valuation_regeneration')):
+ Valuation = Pool().get('valuation.valuation')
+ with Transaction().set_context(
+ _purchase_trade_skip_valuation_regeneration=True):
+ Valuation.regenerate_for_sale_lines(
+ cls.browse(list(valuation_line_ids)))
@classmethod
def _sync_open_lot_quantity(cls, line, vlot, target_quantity):
@@ -3003,6 +3124,7 @@ class ValuationDyn(metaclass=PoolMeta):
Sum(val.amount).as_('r_amount'),
Sum(val.amount_prev).as_('r_amount_prev'),
Sum(val.base_amount).as_('r_base_amount'),
+ Sum(val.pnl).as_('r_pnl'),
Max(val.base_currency).as_('r_base_currency'),
Sum(val.rate).as_('r_rate'),
Avg(val.mtm_price).as_('r_mtm_price'),
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index 7af826b..f10926e 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -9,7 +9,7 @@ from unittest.mock import ANY, Mock, call, patch
from xml.etree import ElementTree
from trytond.pool import Pool
-from trytond.pyson import Eval
+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
@@ -902,6 +902,48 @@ class PurchaseTradeTestCase(ModuleTestCase):
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()
@@ -1059,7 +1101,8 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(len(target), 3)
self.assertEqual(target[0], values)
- self.assertEqual(target[1]['type'], 'mtm')
+ 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'))
@@ -1068,7 +1111,8 @@ class PurchaseTradeTestCase(ModuleTestCase):
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[2]['type'], 'mtm')
+ 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'))
@@ -1077,6 +1121,55 @@ class PurchaseTradeTestCase(ModuleTestCase):
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'
@@ -1547,6 +1640,28 @@ class PurchaseTradeTestCase(ModuleTestCase):
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(
@@ -1567,6 +1682,8 @@ class PurchaseTradeTestCase(ModuleTestCase):
{'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'
@@ -3596,6 +3713,26 @@ class PurchaseTradeTestCase(ModuleTestCase):
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')
@@ -4004,6 +4141,84 @@ class PurchaseTradeTestCase(ModuleTestCase):
'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_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')
diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py
index ab5b8fa..fe7840d 100644
--- a/modules/purchase_trade/valuation.py
+++ b/modules/purchase_trade/valuation.py
@@ -33,6 +33,8 @@ VALTYPE = [
('shipment fee', 'Shipment fee'),
('market', 'Market'),
('mtm', 'Mtm'),
+ ('pur. mtm', 'Pur. Mtm'),
+ ('sale mtm', 'Sale Mtm'),
('derivative', 'Derivative'),
]
@@ -67,6 +69,7 @@ class ValuationBase(ModelSQL):
shipment_in = fields.Many2One('stock.shipment.in', "Shipment In")
fee = fields.Many2One('fee.fee', "Fee")
base_amount = fields.Numeric("Base Amount",digits=(16,2))
+ pnl = fields.Numeric("Pnl", digits=(16, 2))
base_currency = fields.Many2One('currency.currency', "Base Cur")
rate = fields.Numeric("Rate", digits=(16,6))
@@ -77,7 +80,8 @@ class ValuationBase(ModelSQL):
'fees': {'line fee', 'pur. fee', 'sale fee', 'shipment fee'},
'goods': {
'priced', 'pur. priced', 'pur. efp',
- 'sale priced', 'sale efp', 'market', 'mtm',
+ 'sale priced', 'sale efp', 'market',
+ 'mtm', 'pur. mtm', 'sale mtm',
},
'derivatives': {'derivative'},
}
@@ -370,6 +374,12 @@ class ValuationBase(ModelSQL):
seen.add(key)
return unique
+ @classmethod
+ def _prepare_values_for_create(cls, values):
+ for value in values:
+ cls._set_pnl(value, strategy=value.get('strategy'))
+ return values
+
@classmethod
def _snapshot_identity_domain(cls, value):
domain = [
@@ -450,6 +460,30 @@ class ValuationBase(ModelSQL):
def _sale_line_is_unmatched(cls, sale_line):
return not cls._matched_purchase_lines_from_sale_line(sale_line)
+ @classmethod
+ def _unique_records(cls, records):
+ unique = []
+ seen = set()
+ for record in records or []:
+ record_id = cls._record_id(record)
+ key = record_id if record_id is not None else id(record)
+ if not record or key in seen:
+ continue
+ unique.append(record)
+ seen.add(key)
+ return unique
+
+ @classmethod
+ def regenerate_for_purchase_lines(cls, lines, valuation_type='all'):
+ for line in cls._unique_records(lines):
+ cls.generate(line, valuation_type=valuation_type)
+
+ @classmethod
+ def regenerate_for_sale_lines(cls, sale_lines, valuation_type='all'):
+ for sale_line in cls._unique_records(sale_lines):
+ cls.generate_from_sale_line(
+ sale_line, valuation_type=valuation_type)
+
@classmethod
def _delete_existing_sale_line(cls, sale_line, selected_types=None):
Date = Pool().get('ir.date')
@@ -569,6 +603,68 @@ class ValuationBase(ModelSQL):
return -abs(mtm)
return abs(mtm)
+ @staticmethod
+ def _strategy_mtm_type(values):
+ if values.get('type') in {'pur. priced', 'pur. efp'}:
+ return 'pur. mtm'
+ if values.get('type') in {'sale priced', 'sale efp'}:
+ return 'sale mtm'
+ return 'mtm'
+
+ @staticmethod
+ def _coerce_decimal(value):
+ if value in (None, ''):
+ return Decimal(0)
+ return Decimal(str(value))
+
+ @classmethod
+ def _mtm_base_amount(cls, mtm_amount, strategy, values):
+ if mtm_amount in (None, ''):
+ return Decimal(0)
+ mtm_amount = Decimal(str(mtm_amount))
+ source_currency = getattr(strategy, 'currency', None)
+ base_currency = values.get('base_currency')
+ if not source_currency or not base_currency:
+ return mtm_amount
+
+ source_currency_id = cls._record_id(source_currency)
+ base_currency_id = cls._record_id(base_currency)
+ if source_currency_id == base_currency_id:
+ return mtm_amount
+
+ Currency = Pool().get('currency.currency')
+ if isinstance(source_currency, int):
+ source_currency = Currency(source_currency)
+ if isinstance(base_currency, int):
+ base_currency = Currency(base_currency)
+
+ scenario = getattr(strategy, 'scenario', None)
+ conversion_date = (
+ cls._strategy_valuation_date(strategy)
+ if scenario else values.get('date'))
+ if conversion_date:
+ with Transaction().set_context(date=conversion_date):
+ return Currency.compute(
+ source_currency, mtm_amount, base_currency)
+ return Currency.compute(source_currency, mtm_amount, base_currency)
+
+ @classmethod
+ def _pnl_amount(cls, values, strategy=None, mtm_amount=None):
+ base_amount = cls._coerce_decimal(values.get('base_amount'))
+ if mtm_amount is None:
+ mtm_amount = values.get('mtm')
+ mtm_base_amount = (
+ cls._mtm_base_amount(mtm_amount, strategy, values)
+ if strategy else cls._coerce_decimal(mtm_amount))
+ return round(base_amount - mtm_base_amount, 2)
+
+ @classmethod
+ def _set_pnl(cls, values, strategy=None, mtm_amount=None):
+ if values is not None:
+ values['pnl'] = cls._pnl_amount(
+ values, strategy=strategy, mtm_amount=mtm_amount)
+ return values
+
@staticmethod
def _component_weight(component):
ratio = Decimal(component.ratio or 0)
@@ -623,7 +719,7 @@ class ValuationBase(ModelSQL):
mtm_curve=None, mtm_price_prev=None, mtm_amount_prev=None):
line_values = dict(values)
line_values.update({
- 'type': 'mtm',
+ 'type': cls._strategy_mtm_type(values),
'reference': 'MTM/%s' % (values.get('reference') or ''),
'price': None,
'amount': Decimal(0),
@@ -635,6 +731,7 @@ class ValuationBase(ModelSQL):
'mtm': mtm_amount,
'strategy': strategy,
})
+ cls._set_pnl(line_values, strategy=strategy, mtm_amount=mtm_amount)
return line_values
@classmethod
@@ -842,11 +939,13 @@ class ValuationBase(ModelSQL):
def _append_pnl_values(cls, price_lines, values, mtm_source):
if (values and getattr(mtm_source, 'mtm', None)
and cls._supports_strategy_mtm(values)):
+ cls._set_pnl(values)
price_lines.append(values)
for strat in mtm_source.mtm:
cls._append_strategy_mtm_lines(
price_lines, values, strat, mtm_source)
elif values:
+ cls._set_pnl(values)
price_lines.append(values)
@classmethod
@@ -1467,6 +1566,7 @@ class ValuationBase(ModelSQL):
values.extend(cls.create_pnl_der_from_line(line))
values = cls._filter_values_by_types(values, selected_types)
values = cls._dedupe_values(values)
+ values = cls._prepare_values_for_create(values)
cls._delete_existing_snapshot_values(values)
if values:
@@ -1492,6 +1592,7 @@ class ValuationBase(ModelSQL):
values.extend(cls.create_pnl_der_from_sale_line(sale_line))
values = cls._filter_values_by_types(values, selected_types)
values = cls._dedupe_values(values)
+ values = cls._prepare_values_for_create(values)
cls._delete_existing_snapshot_values(values)
if values:
@@ -1589,6 +1690,7 @@ class ValuationDyn(ModelSQL,ModelView):
r_amount = fields.Numeric("Amount",digits='r_unit')
r_amount_prev = fields.Numeric("Amount -1", digits='r_unit')
r_base_amount = fields.Numeric("Base Amount",digits='r_unit')
+ r_pnl = fields.Numeric("Pnl", digits='r_unit')
r_base_currency = fields.Many2One('currency.currency', "Base Cur")
r_rate = fields.Numeric("Rate",digits=(16,6))
r_mtm_price = fields.Numeric("Mtm Price",digits='r_unit')
@@ -1628,6 +1730,7 @@ class ValuationDyn(ModelSQL,ModelView):
Sum(val.amount).as_('r_amount'),
Sum(val.amount_prev).as_('r_amount_prev'),
Sum(val.base_amount).as_('r_base_amount'),
+ Sum(val.pnl).as_('r_pnl'),
Max(val.base_currency).as_('r_base_currency'),
Sum(val.rate).as_('r_rate'),
Avg(val.mtm_price).as_('r_mtm_price'),
@@ -1686,6 +1789,7 @@ class ValuationReport(ValuationBase, ModelView):
val.amount.as_('amount'),
val.amount_prev.as_('amount_prev'),
val.base_amount.as_('base_amount'),
+ val.pnl.as_('pnl'),
val.base_currency.as_('base_currency'),
val.rate.as_('rate'),
val.mtm_price.as_('mtm_price'),
diff --git a/modules/purchase_trade/view/purchase_configuration_form.xml b/modules/purchase_trade/view/purchase_configuration_form.xml
new file mode 100644
index 0000000..2d8995d
--- /dev/null
+++ b/modules/purchase_trade/view/purchase_configuration_form.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/purchase_form.xml b/modules/purchase_trade/view/purchase_form.xml
index 32f2f59..5c7f729 100755
--- a/modules/purchase_trade/view/purchase_form.xml
+++ b/modules/purchase_trade/view/purchase_form.xml
@@ -3,6 +3,9 @@
this repository contains the full copyright notices and license terms. -->
+
+
+