ITSA points 28.06

This commit is contained in:
2026-06-28 13:31:48 +02:00
parent b7b54c8b7b
commit f45cdb0807
18 changed files with 718 additions and 35 deletions

Binary file not shown.

View File

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

View File

@@ -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')],

View File

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

View File

@@ -10,6 +10,16 @@
<field name="type">form</field>
<field name="name">template_configuration_form</field>
</record>
<record model="ir.ui.view" id="purchase_configuration_view_form">
<field name="model">purchase.configuration</field>
<field name="inherit" ref="purchase.configuration_view_form"/>
<field name="name">purchase_configuration_form</field>
</record>
<record model="ir.ui.view" id="sale_configuration_view_form">
<field name="model">sale.configuration</field>
<field name="inherit" ref="sale.configuration_view_form"/>
<field name="name">sale_configuration_form</field>
</record>
<record model="ir.action.act_window" id="act_purchase_trade_configuration_form">
<field name="name">Pricing Configuration</field>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<data>
<xpath expr="/form/label[@name='purchase_process_after']" position="before">
<label name="allow_modification_after_validation"/>
<field name="allow_modification_after_validation"/>
<newline/>
</xpath>
</data>

View File

@@ -3,6 +3,9 @@
this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/group[@id='hd'][2]" position="replace"/>
<xpath expr="/form/field[@name='party_lang']" position="before">
<field name="allow_modification_after_validation" invisible="1"/>
</xpath>
<xpath expr="/form/group[@id='hd'][1]" position="replace">
<group id="purchase_header_summary" colspan="6" col="10"
panel="summary" xalign="0" yalign="0"

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<data>
<xpath expr="/form/label[@name='sale_process_after']" position="before">
<label name="allow_modification_after_validation"/>
<field name="allow_modification_after_validation"/>
<newline/>
</xpath>
</data>

View File

@@ -3,6 +3,9 @@
this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/group[@id='hd'][2]" position="replace"/>
<xpath expr="/form/field[@name='party_lang']" position="before">
<field name="allow_modification_after_validation" invisible="1"/>
</xpath>
<xpath expr="/form/group[@id='hd'][1]" position="replace">
<group id="sale_header_summary" colspan="6" col="10"
panel="summary" xalign="0" yalign="0"

View File

@@ -6,7 +6,7 @@
<field name="fee" optional="1"/>
<field name="type"
widget="badge"
badge_colors="priced:#2563eb,Price:#2563eb,pur. priced:#1d4ed8,Pur. price:#1d4ed8,pur. efp:#38bdf8,Pur. efp:#38bdf8,sale priced:#16a34a,Sale price:#16a34a,sale efp:#86efac,Sale efp:#86efac,market:#64748b,Market:#64748b,line fee:#d97706,Line fee:#d97706,pur. fee:#f59e0b,Pur. fee:#f59e0b,sale fee:#fb923c,Sale fee:#fb923c,shipment fee:#0f766e,Shipment fee:#0f766e,mtm:#9f1239,Mtm:#9f1239,derivative:#8b5cf6,Derivative:#8b5cf6,*:#94a3b8"/>
badge_colors="priced:#2563eb,Price:#2563eb,pur. priced:#1d4ed8,Pur. price:#1d4ed8,pur. efp:#38bdf8,Pur. efp:#38bdf8,sale priced:#16a34a,Sale price:#16a34a,sale efp:#86efac,Sale efp:#86efac,market:#64748b,Market:#64748b,line fee:#d97706,Line fee:#d97706,pur. fee:#f59e0b,Pur. fee:#f59e0b,sale fee:#fb923c,Sale fee:#fb923c,shipment fee:#0f766e,Shipment fee:#0f766e,mtm:#9f1239,Mtm:#9f1239,pur. mtm:#9f1239,Pur. Mtm:#9f1239,sale mtm:#9f1239,Sale Mtm:#9f1239,derivative:#8b5cf6,Derivative:#8b5cf6,*:#94a3b8"/>
<field name="reference"/>
<field name="counterparty"/>
<field name="state"/>
@@ -19,6 +19,7 @@
<field name="currency"/>
<field name="amount_prev" optional="1"/>
<field name="base_amount" sum="1"/>
<field name="pnl" sum="1"/>
<field name="base_currency"/>
<field name="rate"/>
<field name="strategy"/>

View File

@@ -7,7 +7,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="fee" optional="1"/>
<field name="type"
widget="badge"
badge_colors="priced:#2563eb,Price:#2563eb,pur. priced:#1d4ed8,Pur. price:#1d4ed8,pur. efp:#38bdf8,Pur. efp:#38bdf8,sale priced:#16a34a,Sale price:#16a34a,sale efp:#86efac,Sale efp:#86efac,market:#64748b,Market:#64748b,line fee:#d97706,Line fee:#d97706,pur. fee:#f59e0b,Pur. fee:#f59e0b,sale fee:#fb923c,Sale fee:#fb923c,shipment fee:#0f766e,Shipment fee:#0f766e,mtm:#9f1239,Mtm:#9f1239,derivative:#8b5cf6,Derivative:#8b5cf6,*:#94a3b8"/>
badge_colors="priced:#2563eb,Price:#2563eb,pur. priced:#1d4ed8,Pur. price:#1d4ed8,pur. efp:#38bdf8,Pur. efp:#38bdf8,sale priced:#16a34a,Sale price:#16a34a,sale efp:#86efac,Sale efp:#86efac,market:#64748b,Market:#64748b,line fee:#d97706,Line fee:#d97706,pur. fee:#f59e0b,Pur. fee:#f59e0b,sale fee:#fb923c,Sale fee:#fb923c,shipment fee:#0f766e,Shipment fee:#0f766e,mtm:#9f1239,Mtm:#9f1239,pur. mtm:#9f1239,Pur. Mtm:#9f1239,sale mtm:#9f1239,Sale Mtm:#9f1239,derivative:#8b5cf6,Derivative:#8b5cf6,*:#94a3b8"/>
<field name="reference"/>
<field name="counterparty"/>
<field name="state"/>
@@ -20,6 +20,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="currency"/>
<field name="amount_prev" optional="1"/>
<field name="base_amount" sum="1"/>
<field name="pnl" sum="1"/>
<field name="base_currency"/>
<field name="rate"/>
<field name="strategy"/>

View File

@@ -15,6 +15,7 @@ this repository contains the full copyright notices and license terms. -->
sum_previous="r_amount_prev"
sum_variation="1"/>
<field name="r_amount_prev" optional="1"/>
<field name="r_pnl" sum="1"/>
<field name="r_strategy"/>
<field name="r_mtm_curve"/>
<field name="r_mtm_price"