This commit is contained in:
2026-03-31 17:17:12 +02:00
parent 6bf245ac64
commit efee365fc6
8 changed files with 423 additions and 29 deletions

View File

@@ -105,10 +105,13 @@ def register():
purchase.FeeLots,
valuation.Valuation,
valuation.ValuationLine,
valuation.ValuationDyn,
valuation.ValuationReport,
valuation.ValuationReportContext,
derivative.Derivative,
valuation.ValuationDyn,
valuation.ValuationReport,
valuation.ValuationReportContext,
valuation.ValuationProcessDimension,
valuation.ValuationProcessStart,
valuation.ValuationProcessResult,
derivative.Derivative,
derivative.DerivativeMatch,
derivative.MatchWizardStart,
derivative.DerivativeReport,
@@ -258,12 +261,13 @@ def register():
purchase.InvoicePayment,
stock.ImportSoFWizard,
dashboard.BotWizard,
dashboard.DashboardLoader,
forex.ForexReport,
purchase.PnlReport,
purchase.PositionReport,
derivative.DerivativeMatchWizard,
module='purchase', type_='wizard')
dashboard.DashboardLoader,
forex.ForexReport,
purchase.PnlReport,
purchase.PositionReport,
valuation.ValuationProcess,
derivative.DerivativeMatchWizard,
module='purchase', type_='wizard')
Pool.register(
sale.SaleCreatePurchase,
sale.SaleAllocationsWizard,

View File

@@ -6,6 +6,7 @@ from unittest.mock import Mock, patch
from trytond.pool import Pool
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.modules.purchase_trade import valuation as valuation_module
class PurchaseTradeTestCase(ModuleTestCase):
@@ -70,5 +71,43 @@ class PurchaseTradeTestCase(ModuleTestCase):
strategy.get_mtm(line, Decimal('10')),
Decimal('250.00'))
def test_parse_numbers_supports_common_separators(self):
'parse_numbers splits spaces commas semicolons and new lines'
self.assertEqual(
valuation_module.ValuationProcess._parse_numbers(
'PUR-001, PUR-002\nPUR-003;PUR-004'
),
['PUR-001', 'PUR-002', 'PUR-003', 'PUR-004'])
def test_get_generate_types_maps_business_groups(self):
'valuation type groups map to the expected stored valuation types'
Valuation = Pool().get('valuation.valuation')
self.assertEqual(
Valuation._get_generate_types('fees'),
{'line fee', 'pur. fee', 'sale fee', 'shipment fee'})
self.assertEqual(
Valuation._get_generate_types('derivatives'),
{'derivative'})
self.assertIn('pur. priced', Valuation._get_generate_types('goods'))
def test_filter_values_by_types_keeps_matching_entries_only(self):
'type filtering keeps only the requested valuation entries'
Valuation = Pool().get('valuation.valuation')
values = [
{'type': 'pur. fee', 'amount': Decimal('10')},
{'type': 'pur. priced', 'amount': Decimal('20')},
{'type': 'derivative', 'amount': Decimal('30')},
]
self.assertEqual(
Valuation._filter_values_by_types(
values, {'pur. fee', 'derivative'}),
[
{'type': 'pur. fee', 'amount': Decimal('10')},
{'type': 'derivative', 'amount': Decimal('30')},
])
del ModuleTestCase

View File

@@ -1,7 +1,7 @@
from trytond.model import fields
from trytond.report import Report
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id, If
from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder
from trytond.model import (ModelSQL, ModelView)
from trytond.tools import is_full_text, lstrip_wildcard
from trytond.transaction import Transaction, inactive_records
@@ -15,6 +15,7 @@ from itertools import chain, groupby
from operator import itemgetter
import datetime
import logging
import re
from collections import defaultdict
from trytond.exceptions import UserWarning, UserError
@@ -34,6 +35,13 @@ VALTYPE = [
('derivative', 'Derivative'),
]
VALUATION_TYPE_GROUPS = [
('all', 'All'),
('fees', 'PnL Fees'),
('goods', 'PnL Goods'),
('derivatives', 'PnL Derivatives'),
]
class ValuationBase(ModelSQL):
purchase = fields.Many2One('purchase.purchase',"Purchase")
line = fields.Many2One('purchase.line',"Purch. Line")
@@ -54,6 +62,49 @@ class ValuationBase(ModelSQL):
base_amount = fields.Numeric("Base Amount",digits=(16,2))
rate = fields.Numeric("Rate", digits=(16,6))
@classmethod
def _get_generate_types(cls, valuation_type='all'):
type_map = {
'all': None,
'fees': {'line fee', 'pur. fee', 'sale fee', 'shipment fee'},
'goods': {
'priced', 'pur. priced', 'pur. efp',
'sale priced', 'sale efp', 'market',
},
'derivatives': {'derivative'},
}
return type_map.get(valuation_type, None)
@classmethod
def _filter_values_by_types(cls, values, selected_types):
if selected_types is None:
return values
return [value for value in values if value.get('type') in selected_types]
@classmethod
def _delete_existing(cls, line, selected_types=None):
Date = Pool().get('ir.date')
Valuation = Pool().get('valuation.valuation')
ValuationLine = Pool().get('valuation.valuation.line')
valuation_domain = [
('line', '=', line.id),
('date', '=', Date.today()),
]
valuation_line_domain = [('line', '=', line.id)]
if selected_types is not None:
valuation_domain.append(('type', 'in', list(selected_types)))
valuation_line_domain.append(('type', 'in', list(selected_types)))
valuations = Valuation.search(valuation_domain)
if valuations:
Valuation.delete(valuations)
valuation_lines = ValuationLine.search(valuation_line_domain)
if valuation_lines:
ValuationLine.delete(valuation_lines)
@classmethod
def _base_pnl(cls, *, line, lot, pnl_type, sale=None):
Date = Pool().get('ir.date')
@@ -403,26 +454,20 @@ class ValuationBase(ModelSQL):
return der_lines
@classmethod
def generate(cls, line):
Date = Pool().get('ir.date')
Valuation = Pool().get('valuation.valuation')
ValuationLine = Pool().get('valuation.valuation.line')
Valuation.delete(Valuation.search([
('line', '=', line.id),
('date', '=', Date.today()),
]))
ValuationLine.delete(ValuationLine.search([
('line', '=', line.id),
]))
def generate(cls, line, valuation_type='all'):
selected_types = cls._get_generate_types(valuation_type)
cls._delete_existing(line, selected_types=selected_types)
values = []
values.extend(cls.create_pnl_fee_from_line(line))
values.extend(cls.create_pnl_price_from_line(line))
values.extend(cls.create_pnl_der_from_line(line))
values = cls._filter_values_by_types(values, selected_types)
Valuation.create(values)
ValuationLine.create(values)
if values:
Valuation = Pool().get('valuation.valuation')
ValuationLine = Pool().get('valuation.valuation.line')
Valuation.create(values)
ValuationLine.create(values)
class Valuation(ValuationBase, ModelView):
"Valuation"
@@ -606,3 +651,242 @@ class ValuationReportContext(ModelView):
@classmethod
def default_state(cls):
return 'all'
class ValuationProcessDimension(ModelView):
"Valuation Process Dimension"
__name__ = 'valuation.process.dimension'
start = fields.Many2One('valuation.process.start', "Start")
dimension = fields.Many2One(
'analytic.dimension',
'Dimension',
required=True
)
value = fields.Many2One(
'analytic.dimension.value',
'Value',
required=True,
domain=[
('dimension', '=', Eval('dimension')),
],
depends=['dimension']
)
class ValuationProcessStart(ModelView):
"Valuation Process Start"
__name__ = 'valuation.process.start'
purchase_from_date = fields.Date("Purchase From Date")
purchase_to_date = fields.Date("Purchase To Date")
sale_from_date = fields.Date("Sale From Date")
sale_to_date = fields.Date("Sale To Date")
purchase_numbers = fields.Text("Purchase Numbers")
sale_numbers = fields.Text("Sale Numbers")
analytic_dimensions = fields.One2Many(
'valuation.process.dimension',
'start',
'Analytic Dimensions'
)
valuation_type = fields.Selection(
VALUATION_TYPE_GROUPS,
"Type",
required=True
)
@classmethod
def default_valuation_type(cls):
return 'all'
class ValuationProcessResult(ModelView):
"Valuation Process Result"
__name__ = 'valuation.process.result'
message = fields.Text("Message", readonly=True)
class ValuationProcess(Wizard):
"Process Valuation"
__name__ = 'valuation.process'
start = StateView(
'valuation.process.start',
'purchase_trade.valuation_process_start_form',
[
Button('Cancel', 'end', 'tryton-cancel'),
Button('Process', 'process', 'tryton-ok', default=True),
]
)
process = StateTransition()
result = StateView(
'valuation.process.result',
'purchase_trade.valuation_process_result_form',
[
Button('Close', 'end', 'tryton-cancel'),
Button('See Valuation', 'open_report', 'tryton-go-next', default=True),
]
)
open_report = StateAction('purchase_trade.act_valuation_form')
_result_message = None
@staticmethod
def _parse_numbers(text):
if not text:
return []
return [item for item in re.split(r'[\s,;]+', text) if item]
@staticmethod
def _matches_dimensions(record, dimension_filters):
assignments = getattr(record, 'analytic_dimensions', []) or []
assignment_pairs = {
(assignment.dimension.id, assignment.value.id)
for assignment in assignments
if assignment.dimension and assignment.value
}
return all(
(dimension.id, value.id) in assignment_pairs
for dimension, value in dimension_filters
)
@classmethod
def _get_dimension_filters(cls, start):
return [
(line.dimension, line.value)
for line in start.analytic_dimensions or []
if line.dimension and line.value
]
@classmethod
def _search_purchase_ids(cls, start, dimension_filters):
Purchase = Pool().get('purchase.purchase')
domain = []
numbers = cls._parse_numbers(start.purchase_numbers)
if start.purchase_from_date:
domain.append(('purchase_date', '>=', start.purchase_from_date))
if start.purchase_to_date:
domain.append(('purchase_date', '<=', start.purchase_to_date))
if numbers:
domain.append(('number', 'in', numbers))
purchases = Purchase.search(domain)
if dimension_filters:
purchases = [
purchase for purchase in purchases
if cls._matches_dimensions(purchase, dimension_filters)
]
return {purchase.id for purchase in purchases}
@classmethod
def _search_sale_ids(cls, start, dimension_filters):
Sale = Pool().get('sale.sale')
domain = []
numbers = cls._parse_numbers(start.sale_numbers)
if start.sale_from_date:
domain.append(('sale_date', '>=', start.sale_from_date))
if start.sale_to_date:
domain.append(('sale_date', '<=', start.sale_to_date))
if numbers:
domain.append(('number', 'in', numbers))
sales = Sale.search(domain)
if dimension_filters:
sales = [sale for sale in sales if cls._matches_dimensions(sale, dimension_filters)]
return {sale.id for sale in sales}
@classmethod
def _purchase_line_ids_from_purchase_ids(cls, purchase_ids):
if not purchase_ids:
return set()
PurchaseLine = Pool().get('purchase.line')
return {
line.id for line in PurchaseLine.search([('purchase', 'in', list(purchase_ids))])
}
@classmethod
def _purchase_line_ids_from_sale_ids(cls, sale_ids):
if not sale_ids:
return set()
SaleLine = Pool().get('sale.line')
purchase_line_ids = set()
sale_lines = SaleLine.search([('sale', 'in', list(sale_ids))])
for sale_line in sale_lines:
for matched_line in sale_line.get_matched_lines() or []:
if matched_line.lot_p and matched_line.lot_p.line:
purchase_line_ids.add(matched_line.lot_p.line.id)
return purchase_line_ids
@classmethod
def _get_target_purchase_line_ids(cls, start):
PurchaseLine = Pool().get('purchase.line')
dimension_filters = cls._get_dimension_filters(start)
has_purchase_filters = bool(
start.purchase_from_date
or start.purchase_to_date
or cls._parse_numbers(start.purchase_numbers)
)
has_sale_filters = bool(
start.sale_from_date
or start.sale_to_date
or cls._parse_numbers(start.sale_numbers)
)
purchase_side_ids = cls._purchase_line_ids_from_purchase_ids(
cls._search_purchase_ids(
start,
dimension_filters if (dimension_filters and (has_purchase_filters or not has_sale_filters)) else [],
)
) if (has_purchase_filters or (dimension_filters and not has_sale_filters)) else set()
sale_side_ids = cls._purchase_line_ids_from_sale_ids(
cls._search_sale_ids(
start,
dimension_filters if (dimension_filters and (has_sale_filters or not has_purchase_filters)) else [],
)
) if (has_sale_filters or (dimension_filters and not has_purchase_filters)) else set()
if has_purchase_filters and has_sale_filters:
target_ids = purchase_side_ids & sale_side_ids
elif has_purchase_filters:
target_ids = purchase_side_ids
elif has_sale_filters:
target_ids = sale_side_ids
elif dimension_filters:
target_ids = purchase_side_ids | sale_side_ids
else:
target_ids = {line.id for line in PurchaseLine.search([])}
return target_ids
def transition_process(self):
PurchaseLine = Pool().get('purchase.line')
target_ids = self._get_target_purchase_line_ids(self.start)
lines = PurchaseLine.browse(list(target_ids))
purchase_ids = {line.purchase.id for line in lines if line.purchase}
sale_ids = set()
for line in lines:
for matched_line in line.get_matched_lines() or []:
if matched_line.lot_s and matched_line.lot_s.sale_line:
sale_ids.add(matched_line.lot_s.sale_line.sale.id)
Valuation.generate(line, valuation_type=self.start.valuation_type)
self._result_message = (
f"Processed {len(lines)} purchase line(s) "
f"from {len(purchase_ids)} purchase(s) "
f"and {len(sale_ids)} linked sale(s)."
)
return 'result'
def default_result(self, fields):
return {
'message': self._result_message or 'No valuation was processed.',
}
def do_open_report(self, action):
Date = Pool().get('ir.date')
action['pyson_context'] = PYSONEncoder().encode({
'valuation_date': Date.today(),
})
return action, {}

View File

@@ -1,5 +1,25 @@
<tryton>
<data>
<record model="ir.ui.view" id="valuation_process_dimension_view_tree">
<field name="model">valuation.process.dimension</field>
<field name="type">tree</field>
<field name="name">valuation_process_dimension_tree</field>
</record>
<record model="ir.ui.view" id="valuation_process_dimension_view_form">
<field name="model">valuation.process.dimension</field>
<field name="type">form</field>
<field name="name">valuation_process_dimension_form</field>
</record>
<record model="ir.ui.view" id="valuation_process_start_view_form">
<field name="model">valuation.process.start</field>
<field name="type">form</field>
<field name="name">valuation_process_start_form</field>
</record>
<record model="ir.ui.view" id="valuation_process_result_view_form">
<field name="model">valuation.process.result</field>
<field name="type">form</field>
<field name="name">valuation_process_result_form</field>
</record>
<record model="ir.ui.view" id="valuation_view_tree_sequence3">
<field name="model">valuation.valuation</field>
<field name="type">tree</field>
@@ -36,6 +56,10 @@
<field name="res_model">valuation.report</field>
<field name="context_model">valuation.report.context</field>
</record>
<record model="ir.action.wizard" id="act_valuation_process">
<field name="name">Process valuation</field>
<field name="wiz_name">valuation.process</field>
</record>
<record model="ir.action.act_window.view" id="act_valuation_form_view">
<field name="sequence" eval="70"/>
<field name="view" ref="valuation_view_list"/>
@@ -43,9 +67,18 @@
</record>
<menuitem
parent="purchase_trade.menu_global_reporting"
sequence="120"
name="Valuation"
sequence="98"
id="menu_valuation"/>
<menuitem
parent="menu_valuation"
sequence="10"
action="act_valuation_process"
id="menu_valuation_process"/>
<menuitem
parent="menu_valuation"
sequence="20"
action="act_valuation_form"
id="menu_valuation_form"/>
</data>
</tryton>
</tryton>

View File

@@ -0,0 +1,6 @@
<form col="4">
<label name="dimension"/>
<field name="dimension"/>
<label name="value"/>
<field name="value"/>
</form>

View File

@@ -0,0 +1,4 @@
<tree editable="bottom">
<field name="dimension"/>
<field name="value"/>
</tree>

View File

@@ -0,0 +1,3 @@
<form>
<field name="message"/>
</form>

View File

@@ -0,0 +1,21 @@
<form col="4">
<label name="valuation_type"/>
<field name="valuation_type"/>
<newline/>
<separator string="Purchase Filters" colspan="4"/>
<label name="purchase_from_date"/>
<field name="purchase_from_date"/>
<label name="purchase_to_date"/>
<field name="purchase_to_date"/>
<label name="purchase_numbers"/>
<field name="purchase_numbers" colspan="3"/>
<separator string="Sale Filters" colspan="4"/>
<label name="sale_from_date"/>
<field name="sale_from_date"/>
<label name="sale_to_date"/>
<field name="sale_to_date"/>
<label name="sale_numbers"/>
<field name="sale_numbers" colspan="3"/>
<separator string="Analytic Dimensions" colspan="4"/>
<field name="analytic_dimensions" colspan="4"/>
</form>