diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index d3bae59..0a4126c 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -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,
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index 7d2b4c5..a3bd90b 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -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
diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py
index 3dc7cf3..a37fa1b 100644
--- a/modules/purchase_trade/valuation.py
+++ b/modules/purchase_trade/valuation.py
@@ -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, {}
diff --git a/modules/purchase_trade/valuation.xml b/modules/purchase_trade/valuation.xml
index e95de26..bf4d86e 100644
--- a/modules/purchase_trade/valuation.xml
+++ b/modules/purchase_trade/valuation.xml
@@ -1,5 +1,25 @@