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 @@ + + valuation.process.dimension + tree + valuation_process_dimension_tree + + + valuation.process.dimension + form + valuation_process_dimension_form + + + valuation.process.start + form + valuation_process_start_form + + + valuation.process.result + form + valuation_process_result_form + valuation.valuation tree @@ -36,6 +56,10 @@ valuation.report valuation.report.context + + Process valuation + valuation.process + @@ -43,9 +67,18 @@ + + - \ No newline at end of file + diff --git a/modules/purchase_trade/view/valuation_process_dimension_form.xml b/modules/purchase_trade/view/valuation_process_dimension_form.xml new file mode 100644 index 0000000..98ae41d --- /dev/null +++ b/modules/purchase_trade/view/valuation_process_dimension_form.xml @@ -0,0 +1,6 @@ +
+