31.03.26
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<form col="4">
|
||||
<label name="dimension"/>
|
||||
<field name="dimension"/>
|
||||
<label name="value"/>
|
||||
<field name="value"/>
|
||||
</form>
|
||||
@@ -0,0 +1,4 @@
|
||||
<tree editable="bottom">
|
||||
<field name="dimension"/>
|
||||
<field name="value"/>
|
||||
</tree>
|
||||
@@ -0,0 +1,3 @@
|
||||
<form>
|
||||
<field name="message"/>
|
||||
</form>
|
||||
21
modules/purchase_trade/view/valuation_process_start_form.xml
Normal file
21
modules/purchase_trade/view/valuation_process_start_form.xml
Normal 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>
|
||||
Reference in New Issue
Block a user