From 3c4b28b7d02ddee0bbdc3c416d70e640cce13d45 Mon Sep 17 00:00:00 2001 From: Sergi Almacellas Abellana Date: Fri, 7 Oct 2022 15:00:40 +0200 Subject: [PATCH] Add reporting issue9831 review312881002 --- CHANGELOG | 2 + __init__.py | 11 +- doc/design.rst | 48 ++ message.xml | 24 + purchase.xml | 2 +- purchase_reporting.py | 377 +++++++++++++++ purchase_reporting.xml | 456 ++++++++++++++++++ setup.py | 1 + tests/scenario_purchase_reporting.rst | 154 ++++++ tryton.cfg | 1 + view/purchase_reporting_context_form.xml | 17 + .../purchase_reporting_main_graph_expense.xml | 11 + view/purchase_reporting_main_graph_number.xml | 11 + view/purchase_reporting_main_list.xml | 8 + ...porting_main_time_series_graph_expense.xml | 11 + ...eporting_main_time_series_graph_number.xml | 11 + ...rchase_reporting_main_time_series_list.xml | 8 + view/purchase_reporting_product_list.xml | 9 + view/purchase_reporting_supplier_list.xml | 8 + 19 files changed, 1168 insertions(+), 2 deletions(-) create mode 100644 purchase_reporting.py create mode 100644 purchase_reporting.xml create mode 100644 tests/scenario_purchase_reporting.rst create mode 100644 view/purchase_reporting_context_form.xml create mode 100644 view/purchase_reporting_main_graph_expense.xml create mode 100644 view/purchase_reporting_main_graph_number.xml create mode 100644 view/purchase_reporting_main_list.xml create mode 100644 view/purchase_reporting_main_time_series_graph_expense.xml create mode 100644 view/purchase_reporting_main_time_series_graph_number.xml create mode 100644 view/purchase_reporting_main_time_series_list.xml create mode 100644 view/purchase_reporting_product_list.xml create mode 100644 view/purchase_reporting_supplier_list.xml diff --git a/CHANGELOG b/CHANGELOG index ccc1963e55..106ee5634e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add reporting + Version 6.4.0 - 2022-05-02 * Bug fixes (see mercurial logs for details) * Add supplier currency on party diff --git a/__init__.py b/__init__.py index 1227881dd0..998caa3a7b 100644 --- a/__init__.py +++ b/__init__.py @@ -3,7 +3,9 @@ from trytond.pool import Pool -from . import configuration, invoice, party, product, purchase, stock +from . import ( + configuration, invoice, party, product, purchase, purchase_reporting, + stock) def register(): @@ -35,6 +37,13 @@ def register(): party.SupplierLeadTime, party.PartySupplierCurrency, purchase.ReturnPurchaseStart, + purchase_reporting.Context, + purchase_reporting.Main, + purchase_reporting.MainTimeseries, + purchase_reporting.Supplier, + purchase_reporting.SupplierTimeseries, + purchase_reporting.Product, + purchase_reporting.ProductTimeseries, module='purchase', type_='model') Pool.register( purchase.PurchaseReport, diff --git a/doc/design.rst b/doc/design.rst index 02335e0b35..138eb1efd8 100644 --- a/doc/design.rst +++ b/doc/design.rst @@ -143,6 +143,54 @@ quantities. .. |Products --> Products --> Suppliers| replace:: :menuselection:`Products --> Products --> Suppliers` __ https://demo.tryton.org/model/purchase.product_supplier +.. _concept-purchase.reporting: + +Purchase Reporting +================== + +Each of the different *Purchase Reporting* concepts are based on either an +``Abstract`` purchases report, or an ``AbstractTimeseries``. +There is also a base ``Context`` that is inherited and used to specify things +such as the date range that is used by the report. + +The ``Abstract`` provides the basic properties that makes up a purchases report +including the number of purchases and expense. +The ``AbstractTimeseries`` is used to extend this with a date. +This is used in purchases reports that cover multiple periods of time. +These are combined together with additional specific properties to create the +different purchases reports. + +.. seealso:: + + Purchases reports can be accessed from the main menu item: + + :menuselection:`Purchases --> Reporting --> Purchases` + +.. _model-purchase.reporting.supplier: +.. _model-purchase.reporting.supplier.time_series: + +By Supplier +----------- + +The purchases reporting that is done *By Supplier* splits the purchases up +based on what each `Supplier ` bought. +This is done in two separate parts. +One that shows the purchases, in total, for the selected period from the +``Context``. +Another that breaks them down by date into smaller chunks. + +.. _model-purchase.reporting.product: +.. _model-purchase.reporting.product.time_series: + +By Product +---------- + +Purchases reporting that is done *By Product* splits up the purchases based on +the `Product ` that was purchased. +This is structured as two parts. +One that shows the total purchases for the selected period of time, and another +that shows how the purchases were distributed over time. + .. _model-purchase.configuration: Configuration diff --git a/message.xml b/message.xml index 096874c201..930e986efe 100644 --- a/message.xml +++ b/message.xml @@ -39,5 +39,29 @@ this repository contains the full copyright notices and license terms. --> A tax can be added only once to a purchase line. + + Company + + + # + + + Number of purchases. + + + Expense + + + Expense Trend + + + Currency + + + Date + + + Time Series + diff --git a/purchase.xml b/purchase.xml index 51c234b930..e05b5133d4 100644 --- a/purchase.xml +++ b/purchase.xml @@ -215,7 +215,7 @@ this repository contains the full copyright notices and license terms. --> parent="menu_purchase" sequence="100" id="menu_reporting" - active="0"/> + active="True"/> diff --git a/purchase_reporting.py b/purchase_reporting.py new file mode 100644 index 0000000000..77f19af385 --- /dev/null +++ b/purchase_reporting.py @@ -0,0 +1,377 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +from itertools import tee, zip_longest + +try: + import pygal +except ImportError: + pygal = None +from dateutil.relativedelta import relativedelta +from sql import Literal, Null +from sql.aggregate import Count, Min, Sum +from sql.conditionals import Coalesce +from sql.functions import CurrentTimestamp, DateTrunc + +from trytond.i18n import lazy_gettext +from trytond.model import ModelSQL, ModelView, fields +from trytond.modules.currency.fields import Monetary +from trytond.pool import Pool +from trytond.pyson import Eval, If +from trytond.transaction import Transaction + + +def pairwise(iterable): + a, b = tee(iterable) + next(b) + return zip_longest(a, b) + + +class Abstract(ModelSQL): + + company = fields.Many2One( + 'company.company', + lazy_gettext("purchase.msg_purchase_reporting_company")) + number = fields.Integer( + lazy_gettext("purchase.msg_purchase_reporting_number"), + help=lazy_gettext("purchase.msg_purchase_reporting_number_help")) + expense = Monetary( + lazy_gettext("purchase.msg_purchase_reporting_expense"), + digits='currency', currency='currency') + expense_trend = fields.Function( + fields.Char( + lazy_gettext("purchase.msg_purchase_reporting_expense_trend")), + 'get_trend') + time_series = None + currency = fields.Many2One( + 'currency.currency', + lazy_gettext("purchase.msg_purchase_reporting_currency")) + + @classmethod + def table_query(cls): + from_item, tables = cls._joins() + return from_item.select(*cls._columns(tables), + where=cls._where(tables), + group_by=cls._group_by(tables)) + + @classmethod + def _joins(cls): + pool = Pool() + Line = pool.get('purchase.line') + Purchase = pool.get('purchase.purchase') + + tables = {} + tables['line'] = line = Line.__table__() + tables['line.purchase'] = purchase = Purchase.__table__() + + from_item = (line + .join(purchase, condition=line.purchase == purchase.id)) + return from_item, tables + + @classmethod + def _columns(cls, tables): + line = tables['line'] + purchase = tables['line.purchase'] + + quantity = Coalesce(line.actual_quantity, line.quantity) + expense = cls.expense.sql_cast( + Sum(quantity * line.unit_price)) + return [ + cls._column_id(tables).as_('id'), + Literal(0).as_('create_uid'), + CurrentTimestamp().as_('create_date'), + cls.write_uid.sql_cast(Literal(Null)).as_('write_uid'), + cls.write_date.sql_cast(Literal(Null)).as_('write_date'), + purchase.company.as_('company'), + purchase.currency.as_('currency'), + expense.as_('expense'), + Count(purchase.id, distinct=True).as_('number'), + ] + + @classmethod + def _column_id(cls, tables): + line = tables['line'] + return Min(line.id) + + @classmethod + def _group_by(cls, tables): + purchase = tables['line.purchase'] + return [purchase.company, purchase.currency] + + @classmethod + def _where(cls, tables): + context = Transaction().context + purchase = tables['line.purchase'] + + where = purchase.company == context.get('company') + where &= purchase.state.in_(cls._purchase_states()) + from_date = context.get('from_date') + if from_date: + where &= purchase.purchase_date >= from_date + to_date = context.get('to_date') + if to_date: + where &= purchase.purchase_date <= to_date + warehouse = context.get('warehouse') + if warehouse: + where &= purchase.warehouse == warehouse + return where + + @classmethod + def _purchase_states(cls): + return ['confirmed', 'processing', 'done'] + + @property + def time_series_all(self): + delta = self._period_delta() + for ts, next_ts in pairwise(self.time_series or []): + yield ts + if delta and next_ts: + date = ts.date + delta + while date < next_ts.date: + yield None + date += delta + + @classmethod + def _period_delta(cls): + context = Transaction().context + return { + 'year': relativedelta(years=1), + 'month': relativedelta(months=1), + 'day': relativedelta(days=1), + }.get(context.get('period')) + + def get_trend(self, name): + name = name[:-len('_trend')] + if pygal: + chart = pygal.Line() + chart.add('', [getattr(ts, name) if ts else 0 + for ts in self.time_series_all]) + return chart.render_sparktext() + + +class AbstractTimeseries(Abstract): + + date = fields.Date(lazy_gettext('purchase.msg_purchase_reporting_date')) + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order = [('date', 'ASC')] + + @classmethod + def _columns(cls, tables): + return super()._columns(tables) + [ + cls._column_date(tables).as_('date')] + + @classmethod + def _column_date(cls, tables): + context = Transaction().context + purchase = tables['line.purchase'] + date = DateTrunc(context.get('period'), purchase.purchase_date) + date = cls.date.sql_cast(date) + return date + + @classmethod + def _group_by(cls, tables): + return super()._group_by(tables) + [cls._column_date(tables)] + + +class Context(ModelView): + "Purchase Reporting Context" + __name__ = 'purchase.reporting.context' + + company = fields.Many2One('company.company', "Company", required=True) + from_date = fields.Date("From Date", + domain=[ + If(Eval('to_date') & Eval('from_date'), + ('from_date', '<=', Eval('to_date')), + ()), + ], + depends=['to_date']) + to_date = fields.Date("To Date", + domain=[ + If(Eval('from_date') & Eval('to_date'), + ('to_date', '>=', Eval('from_date')), + ()), + ], + depends=['from_date']) + period = fields.Selection([ + ('year', "Year"), + ('month', "Month"), + ('day', "Day"), + ], "Period", required=True) + warehouse = fields.Many2One( + 'stock.location', "Warehouse", + domain=[ + ('type', '=', 'warehouse'), + ]) + + @classmethod + def default_company(cls): + return Transaction().context.get('company') + + @classmethod + def default_from_date(cls): + pool = Pool() + Date = pool.get('ir.date') + context = Transaction().context + if 'from_date' in context: + return context['from_date'] + return Date.today() - relativedelta(years=1) + + @classmethod + def default_to_date(cls): + pool = Pool() + Date = pool.get('ir.date') + context = Transaction().context + if 'to_date' in context: + return context['to_date'] + return Date.today() + + @classmethod + def default_period(cls): + return Transaction().context.get('period', 'month') + + @classmethod + def default_warehouse(cls): + return Transaction().context.get('warehouse') + + +class Main(Abstract, ModelView): + "Purchase Reporting" + __name__ = 'purchase.reporting.main' + + time_series = fields.Function(fields.One2Many( + 'purchase.reporting.main.time_series', None, + lazy_gettext('purchase.msg_purchase_reporting_time_series')), + 'get_time_series') + + def get_rec_name(self, name): + return '' + + def get_time_series(self, name): + pool = Pool() + Timeseries = pool.get('purchase.reporting.main.time_series') + return [t.id for t in Timeseries.search([])] + + +class MainTimeseries(AbstractTimeseries, ModelView): + "Purchase Reporting" + __name__ = 'purchase.reporting.main.time_series' + + +class SupplierMixin(object): + __slots__ = () + supplier = fields.Many2One( + 'party.party', "Supplier", + context={ + 'company': Eval('company', -1), + }, + depends=['company']) + + @classmethod + def _columns(cls, tables): + purchase = tables['line.purchase'] + return super()._columns(tables) + [purchase.party.as_('supplier')] + + @classmethod + def _group_by(cls, tables): + purchase = tables['line.purchase'] + return super()._group_by(tables) + [purchase.party] + + def get_rec_name(self, name): + return self.supplier.rec_name + + +class Supplier(SupplierMixin, Abstract, ModelView): + "Purchase Reporting per Supplier" + __name__ = 'purchase.reporting.supplier' + + time_series = fields.One2Many( + 'purchase.reporting.supplier.time_series', 'supplier', + lazy_gettext('purchase.msg_purchase_reporting_time_series')), + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order.insert(0, ('supplier', 'ASC')) + + @classmethod + def _column_id(cls, tables): + purchase = tables['line.purchase'] + return purchase.party + + +class SupplierTimeseries(SupplierMixin, AbstractTimeseries, ModelView): + "Purchase Reporting per Supplier" + __name__ = 'purchase.reporting.supplier.time_series' + + +class ProductMixin(object): + __slots__ = () + product = fields.Many2One( + 'product.product', "Product", + context={ + 'company': Eval('company', -1), + }, + depends=['company']) + product_supplier = fields.Many2One( + 'purchase.product_supplier', "Supplier's Product") + + @classmethod + def _columns(cls, tables): + line = tables['line'] + return super()._columns(tables) + [ + line.product.as_('product'), + line.product_supplier.as_('product_supplier'), + ] + + @classmethod + def _group_by(cls, tables): + line = tables['line'] + return super()._group_by(tables) + [ + line.product, line.product_supplier] + + @classmethod + def _where(cls, tables): + context = Transaction().context + line = tables['line'] + purchase = tables['line.purchase'] + where = super()._where(tables) + where &= line.product != Null + where &= purchase.party == context.get('supplier') + return where + + def get_rec_name(self, name): + pool = Pool() + Party = pool.get('party.party') + context = Transaction().context + + name = self.product.rec_name if self.product else None + if context.get('supplier'): + supplier = Party(context['supplier']) + name += '@%s' % supplier.rec_name + return name + + +class Product(ProductMixin, Abstract, ModelView): + "Purchase Reporting per Product" + __name__ = 'purchase.reporting.product' + + time_series = fields.One2Many( + 'purchase.reporting.product.time_series', 'product', + lazy_gettext('purchase.msg_purchase_reporting_time_series')), + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order.insert(0, ('product', 'ASC')) + + @classmethod + def _column_id(cls, tables): + line = tables['line'] + return line.product + + +class ProductTimeseries(ProductMixin, AbstractTimeseries, ModelView): + "Purchase Reporting per Product" + __name__ = 'purchase.reporting.product.time_series' diff --git a/purchase_reporting.xml b/purchase_reporting.xml new file mode 100644 index 0000000000..41bd2c4b84 --- /dev/null +++ b/purchase_reporting.xml @@ -0,0 +1,456 @@ + + + + + + + + + purchase.reporting.context + form + purchase_reporting_context_form + + + + purchase.reporting.main + tree + purchase_reporting_main_list + + + + purchase.reporting.main + graph + purchase_reporting_main_graph_expense + + + + purchase.reporting.main + graph + purchase_reporting_main_graph_number + + + + Purchases + purchase.reporting.main + purchase.reporting.context + + + + + + + + tree_open + + + + + + User in companies + + + + + + + + + + + + + + + + + + + + + + + + + + purchase.reporting.main.time_series + tree + purchase_reporting_main_time_series_list + + + + purchase.reporting.main.time_series + graph + purchase_reporting_main_time_series_graph_expense + + + + purchase.reporting.main.time_series + graph + purchase_reporting_main_time_series_graph_number + + + + Purchases + purchase.reporting.main.time_series + purchase.reporting.context + + + + + + + + + + + + + + + + + + + tree_open + purchase.reporting.main,-1 + + + + + User in companies + + + + + + + + + + + + + + + + + + + + + + + + + + + + purchase.reporting.supplier + + purchase_reporting_supplier_list + + + + purchase.reporting.supplier + + purchase_reporting_supplier_graph_expense + + + + purchase.reporting.supplier + + purchase_reporting_supplier_graph_number + + + + Purchases per Supplier + purchase.reporting.supplier + purchase.reporting.context + + + + + + + + + + + + + + + + + + tree_open + + + + + + User in companies + + + + + + + + + + + + + + + + + + + + + + + + + + purchase.reporting.supplier.time_series + tree + purchase_reporting_main_time_series_list + + + + purchase.reporting.supplier.time_series + graph + purchase_reporting_main_time_series_graph_expense + + + + purchase.reporting.supplier.time_series + graph + purchase_reporting_main_time_series_graph_number + + + + Purchases per Supplier + purchase.reporting.supplier.time_series + purchase.reporting.context + + + + + + + + + + + + + + + + + + + + tree_open + purchase.reporting.supplier,-1 + + + + + User in companies + + + + + + + + + + + + + + + + + + + + + + + + + + + + purchase.reporting.product + + purchase_reporting_product_list + + + + purchase.reporting.product + + purchase_reporting_product_graph_expense + + + + purchase.reporting.product + + purchase_reporting_product_graph_number + + + + Purchases per Product + purchase.reporting.product + purchase.reporting.context + + + + + + + + + + + + + + + + + + + tree_open + purchase.reporting.supplier,-1 + + + + + User in companies + + + + + + + + + + + + + + + + + + + + + + + + + + purchase.reporting.product.time_series + tree + purchase_reporting_main_time_series_list + + + + purchase.reporting.product.time_series + graph + purchase_reporting_main_time_series_graph_expense + + + + purchase.reporting.product.time_series + graph + purchase_reporting_main_time_series_graph_number + + + + Purchases per Product + purchase.reporting.product.time_series + purchase.reporting.context + + + + + + + + + + + + + + + + + + + + tree_open + purchase.reporting.product,-1 + + + + + User in companies + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/setup.py b/setup.py index 84f66f0c59..a4e03ecb40 100755 --- a/setup.py +++ b/setup.py @@ -142,6 +142,7 @@ setup(name=name, python_requires='>=3.7', install_requires=requires, extras_require={ + 'sparklines': ['pygal'], 'test': tests_require, }, dependency_links=dependency_links, diff --git a/tests/scenario_purchase_reporting.rst b/tests/scenario_purchase_reporting.rst new file mode 100644 index 0000000000..c97403a2d2 --- /dev/null +++ b/tests/scenario_purchase_reporting.rst @@ -0,0 +1,154 @@ +=========================== +Purchase Reporting Scenario +=========================== + +Imports:: + + >>> from dateutil.relativedelta import relativedelta + >>> from decimal import Decimal + >>> from proteus import Model + >>> from trytond.tests.tools import activate_modules + >>> from trytond.modules.company.tests.tools import ( + ... create_company, get_company) + >>> from trytond.modules.account.tests.tools import ( + ... create_fiscalyear, create_chart, get_accounts) + >>> from trytond.modules.account_invoice.tests.tools import ( + ... set_fiscalyear_invoice_sequences) + +Activate modules:: + + >>> config = activate_modules('purchase') + >>> Party = Model.get('party.party') + >>> ProductUom = Model.get('product.uom') + >>> ProductTemplate = Model.get('product.template') + >>> Purchase = Model.get('purchase.purchase') + >>> Supplier = Model.get('purchase.reporting.supplier') + >>> SupplierTimeseries = Model.get( + ... 'purchase.reporting.supplier.time_series') + >>> Product = Model.get('purchase.reporting.product') + >>> ProductTimeseries = Model.get('purchase.reporting.product.time_series') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create fiscal year:: + + >>> fiscalyear = set_fiscalyear_invoice_sequences( + ... create_fiscalyear(company)) + >>> fiscalyear.click('create_period') + +Create chart of accounts:: + + >>> _ = create_chart(company) + >>> accounts = get_accounts(company) + >>> expense = accounts['expense'] + +Create parties:: + + >>> supplier1 = Party(name='Supplier1') + >>> supplier1.save() + >>> supplier2 = Party(name='Supplier2') + >>> supplier2.save() + +Create products:: + + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + + >>> template1 = ProductTemplate() + >>> template1.name = "Product1" + >>> template1.default_uom = unit + >>> template1.type = 'service' + >>> template1.purchasable = True + >>> template1.list_price = Decimal('20') + >>> template1.save() + >>> product1, = template1.products + >>> product1.cost_price = Decimal('10') + >>> product1.save() + + >>> template2, = template1.duplicate(default={'name': "Product2"}) + >>> product2, = template2.products + >>> product2.cost_price = Decimal('10') + >>> product2.save() + +Create purchases:: + + >>> purchase1 = Purchase() + >>> purchase1.party = supplier1 + >>> purchase1.purchase_date = fiscalyear.start_date + >>> line = purchase1.lines.new() + >>> line.product = product1 + >>> line.quantity = 2 + >>> line = purchase1.lines.new() + >>> line.product = product2 + >>> line.quantity = 1 + >>> purchase1.click('quote') + >>> purchase1.click('confirm') + + >>> purchase2 = Purchase() + >>> purchase2.party = supplier2 + >>> purchase2.purchase_date = ( + ... fiscalyear.start_date + relativedelta(months=1)) + >>> line = purchase2.lines.new() + >>> line.product = product1 + >>> line.quantity = 1 + >>> purchase2.click('quote') + >>> purchase2.click('confirm') + +Check purchase reporting per supplier:: + + >>> context = dict( + ... from_date=fiscalyear.start_date, + ... to_date=fiscalyear.end_date, + ... period='month') + >>> with config.set_context(context=context): + ... reports = Supplier.find([]) + ... time_series = SupplierTimeseries.find([]) + >>> len(reports) + 2 + >>> with config.set_context(context=context): + ... sorted((r.supplier.id, r.number, r.expense) for r in reports) == \ + ... sorted([(supplier1.id, 1, Decimal('30')), + ... (supplier2.id, 1, Decimal('10'))]) + True + >>> len(time_series) + 2 + >>> purchase1_ts_date = purchase1.purchase_date.replace(day=1) + >>> purchase2_ts_date = purchase2.purchase_date.replace(day=1) + >>> with config.set_context(context=context): + ... sorted((r.supplier.id, r.date, r.number, r.expense) + ... for r in time_series) == sorted( + ... [(supplier1.id, purchase1_ts_date, 1, Decimal('30')), + ... (supplier2.id, purchase2_ts_date, 1, Decimal('10'))]) + True + +Check purchase reporting per product without supplier:: + + >>> with config.set_context(context=context): + ... reports = Product.find([]) + ... time_series = ProductTimeseries.find([]) + >>> len(reports) + 0 + +Check purchase reporting per product with supplier:: + + >>> context['supplier'] = supplier1.id + >>> with config.set_context(context=context): + ... reports = Product.find([]) + ... time_series = ProductTimeseries.find([]) + >>> len(reports) + 2 + >>> with config.set_context(context=context): + ... sorted((r.product.id, r.number, r.expense) for r in reports) == \ + ... sorted([(product1.id, 1, Decimal('20')), + ... (product2.id, 1, Decimal('10'))]) + True + >>> len(time_series) + 2 + >>> with config.set_context(context=context): + ... sorted((r.product.id, r.date, r.number, r.expense) + ... for r in time_series) == sorted( + ... [(product1.id, purchase1_ts_date, 1, Decimal('20')), + ... (product2.id, purchase1_ts_date, 1, Decimal('10'))]) + True diff --git a/tryton.cfg b/tryton.cfg index f42bd4b2b9..09e68df12a 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -14,6 +14,7 @@ depends: stock xml: purchase.xml + purchase_reporting.xml configuration.xml party.xml stock.xml diff --git a/view/purchase_reporting_context_form.xml b/view/purchase_reporting_context_form.xml new file mode 100644 index 0000000000..c410145021 --- /dev/null +++ b/view/purchase_reporting_context_form.xml @@ -0,0 +1,17 @@ + + +
+ + +