changeset 95fda395767f in modules/sale:default
details: https://hg.tryton.org/modules/sale?cmd=changeset&node=95fda395767f
description:
Add reporting per customer category
issue10488
review331031003
diffstat:
CHANGELOG | 2 +
__init__.py | 3 +
sale_reporting.py | 193
++++++++++
sale_reporting.xml | 166
++++++++
tests/scenario_sale_reporting.rst | 52 ++
view/sale_reporting_customer_category_list.xml | 9 +
view/sale_reporting_customer_category_time_series_graph_number.xml | 11 +
view/sale_reporting_customer_category_time_series_graph_revenue.xml | 11 +
view/sale_reporting_customer_category_time_series_list.xml | 8 +
view/sale_reporting_customer_category_tree.xml | 7 +
10 files changed, 462 insertions(+), 0 deletions(-)
diffs (559 lines):
diff -r 5ce863742e26 -r 95fda395767f CHANGELOG
--- a/CHANGELOG Sun Jul 04 17:54:47 2021 +0200
+++ b/CHANGELOG Mon Jul 12 23:28:15 2021 +0200
@@ -1,3 +1,5 @@
+* Add reporting per customer category
+
Version 6.0.0 - 2021-05-03
* Bug fixes (see mercurial logs for details)
* Add default lead time for products
diff -r 5ce863742e26 -r 95fda395767f __init__.py
--- a/__init__.py Sun Jul 04 17:54:47 2021 +0200
+++ b/__init__.py Mon Jul 12 23:28:15 2021 +0200
@@ -37,6 +37,9 @@
sale_reporting.Context,
sale_reporting.Customer,
sale_reporting.CustomerTimeseries,
+ sale_reporting.CustomerCategory,
+ sale_reporting.CustomerCategoryTimeseries,
+ sale_reporting.CustomerCategoryTree,
sale_reporting.Product,
sale_reporting.ProductTimeseries,
sale_reporting.ProductCategory,
diff -r 5ce863742e26 -r 95fda395767f sale_reporting.py
--- a/sale_reporting.py Sun Jul 04 17:54:47 2021 +0200
+++ b/sale_reporting.py Mon Jul 12 23:28:15 2021 +0200
@@ -321,6 +321,199 @@
__name__ = 'sale.reporting.customer.time_series'
+class CustomerCategoryMixin:
+ __slots__ = ()
+ category = fields.Many2One('party.category', "Category")
+
+ @classmethod
+ def _joins(cls):
+ pool = Pool()
+ PartyCategory = pool.get('party.party-party.category')
+ from_item, tables, withs = super()._joins()
+ if 'line.sale.party.party_category' not in tables:
+ party_category = PartyCategory.__table__()
+ tables['line.sale.party.party_category'] = party_category
+ sale = tables['line.sale']
+ from_item = (from_item
+ .join(party_category,
+ condition=sale.party == party_category.party))
+ return from_item, tables, withs
+
+ @classmethod
+ def _columns(cls, tables, withs):
+ party_category = tables['line.sale.party.party_category']
+ return super()._columns(tables, withs) + [
+ party_category.category.as_('category')]
+
+ @classmethod
+ def _column_id(cls, tables, withs):
+ pool = Pool()
+ Category = pool.get('party.category')
+ category = Category.__table__()
+ line = tables['line']
+ party_category = tables['line.sale.party.party_category']
+ # Get a stable number of categories over time
+ # by using a number one order bigger.
+ nb_category = category.select(
+ Power(10, (Ceil(Log(Max(category.id))) + Literal(1))))
+ return Min(line.id * nb_category + party_category.id)
+
+ @classmethod
+ def _group_by(cls, tables, withs):
+ party_category = tables['line.sale.party.party_category']
+ return super()._group_by(tables, withs) + [party_category.category]
+
+ @classmethod
+ def _where(cls, tables, withs):
+ party_category = tables['line.sale.party.party_category']
+ where = super()._where(tables, withs)
+ where &= party_category.category != Null
+ return where
+
+ def get_rec_name(self, name):
+ return self.category.rec_name if self.category else None
+
+
+class CustomerCategory(CustomerCategoryMixin, Abstract, ModelView):
+ "Sale Reporting per Customer Category"
+ __name__ = 'sale.reporting.customer.category'
+
+ time_series = fields.One2Many(
+ 'sale.reporting.customer.category.time_series', 'category',
+ "Time Series")
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order.insert(0, ('category', 'ASC'))
+
+ @classmethod
+ def _column_id(cls, tables, withs):
+ party_category = tables['line.sale.party.party_category']
+ return party_category.category
+
+
+class CustomerCategoryTimeseries(
+ CustomerCategoryMixin, AbstractTimeseries, ModelView):
+ "Sale Reporting per Customer Category"
+ __name__ = 'sale.reporting.customer.category.time_series'
+
+
+class CustomerCategoryTree(ModelSQL, ModelView):
+ "Sale Reporting per Customer Category"
+ __name__ = 'sale.reporting.customer.category.tree'
+
+ name = fields.Function(fields.Char("Name"), 'get_name')
+ parent = fields.Many2One('sale.reporting.customer.category.tree', "Parent")
+ children = fields.One2Many(
+ 'sale.reporting.customer.category.tree', 'parent', "Children")
+ revenue = fields.Function(
+ fields.Numeric("Revenue", digits=(16, Eval('currency_digits', 2)),
+ depends=['currency_digits']), 'get_total')
+
+ currency_digits = fields.Function(
+ fields.Integer("Currency Digits"), 'get_currency_digits')
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order.insert(0, ('name', 'ASC'))
+
+ @classmethod
+ def table_query(cls):
+ pool = Pool()
+ Category = pool.get('party.category')
+ return Category.__table__()
+
+ @classmethod
+ def get_name(cls, categories, name):
+ pool = Pool()
+ Category = pool.get('party.category')
+ categories = Category.browse(categories)
+ return {c.id: c.name for c in categories}
+
+ @classmethod
+ def order_name(cls, tables):
+ pool = Pool()
+ Category = pool.get('party.category')
+ table, _ = tables[None]
+ if 'category' not in tables:
+ category = Category.__table__()
+ tables['category'] = {
+ None: (category, table.id == category.id),
+ }
+ return Category.name.convert_order(
+ 'name', tables['category'], Category)
+
+ def time_series_all(self):
+ return []
+
+ @classmethod
+ def get_total(cls, categories, names):
+ pool = Pool()
+ ReportingCustomerCategory = pool.get(
+ 'sale.reporting.customer.category')
+ table = cls.__table__()
+ reporting_product_category = ReportingCustomerCategory.__table__()
+ cursor = Transaction().connection.cursor()
+
+ categories = cls.search([
+ ('parent', 'child_of', [c.id for c in categories]),
+ ])
+ ids = [c.id for c in categories]
+ parents = {}
+ reporting_customer_categories = []
+ for sub_ids in grouped_slice(ids):
+ sub_ids = list(sub_ids)
+ where = reduce_ids(table.id, sub_ids)
+ cursor.execute(*table.select(table.id, table.parent, where=where))
+ parents.update(cursor)
+
+ where = reduce_ids(reporting_product_category.id, sub_ids)
+ cursor.execute(
+ *reporting_product_category.select(
+ reporting_product_category.id, where=where))
+ reporting_customer_categories.extend(r for r, in cursor)
+
+ result = {}
+ reporting_product_categories = ReportingCustomerCategory.browse(
+ reporting_customer_categories)
+ for name in names:
+ values = dict.fromkeys(ids, 0)
+ values.update(
+ (c.id, getattr(c, name)) for c in reporting_product_categories)
+ result[name] = cls._sum_tree(categories, values, parents)
+ return result
+
+ @classmethod
+ def _sum_tree(cls, categories, values, parents):
+ result = values.copy()
+ categories = set((c.id for c in categories))
+ leafs = categories - set(parents.values())
+ while leafs:
+ for category in leafs:
+ categories.remove(category)
+ parent = parents.get(category)
+ if parent in result:
+ result[parent] += result[category]
+ next_leafs = set(categories)
+ for category in categories:
+ parent = parents.get(category)
+ if not parent:
+ continue
+ if parent in next_leafs and parent in categories:
+ next_leafs.remove(parent)
+ leafs = next_leafs
+ return result
+
+ def get_currency_digits(self, name):
+ pool = Pool()
+ Company = pool.get('company.company')
+ company = Transaction().context.get('company')
+ if company:
+ return Company(company).currency.digits
+
+
class ProductMixin(object):
__slots__ = ()
product = fields.Many2One(
diff -r 5ce863742e26 -r 95fda395767f sale_reporting.xml
--- a/sale_reporting.xml Sun Jul 04 17:54:47 2021 +0200
+++ b/sale_reporting.xml Mon Jul 12 23:28:15 2021 +0200
@@ -46,6 +46,10 @@
<field name="name">Sales per Customer</field>
<field name="res_model">sale.reporting.customer</field>
<field name="context_model">sale.reporting.context</field>
+ <field
+ name="domain"
+ eval="If(Eval('active_model') ==
'sale.reporting.customer.category.tree', [('customer', 'where', [('categories',
'child_of', Eval('active_ids', []), 'parent')])], [])"
+ pyson="1"/>
</record>
<record model="ir.action.act_window.view"
id="act_reporting_customer_view1">
<field name="sequence" eval="10"/>
@@ -67,6 +71,11 @@
<field name="model" eval="'ir.ui.menu,%s' %
ref('menu_reporting_sale')"/>
<field name="action" ref="act_reporting_customer"/>
</record>
+ <record model="ir.action.keyword" id="act_reporting_customer_keyword2">
+ <field name="keyword">tree_open</field>
+ <field
name="model">sale.reporting.customer.category.tree,-1</field>
+ <field name="action" ref="act_reporting_customer"/>
+ </record>
<record model="ir.rule.group"
id="rule_group_reporting_customer_companies">
<field name="name">User in companies</field>
@@ -173,6 +182,163 @@
<field name="perm_delete" eval="False"/>
</record>
+ <!-- Customer Category -->
+
+ <record model="ir.ui.view" id="reporting_customer_category_view_tree">
+ <field name="model">sale.reporting.customer.category.tree</field>
+ <field name="type">tree</field>
+ <field name="field_childs">children</field>
+ <field name="name">sale_reporting_customer_category_tree</field>
+ </record>
+
+ <record model="ir.action.act_window"
id="act_reporting_customer_category_tree">
+ <field name="name">Sales per Customer Category</field>
+ <field
name="res_model">sale.reporting.customer.category.tree</field>
+ <field name="context_model">sale.reporting.context</field>
+ <field name="domain" eval="[('parent', '=', None)]" pyson="1"/>
+ </record>
+ <record model="ir.action.act_window.view"
id="act_reporting_customer_category_tree_view1">
+ <field name="sequence" eval="10"/>
+ <field name="view" ref="reporting_customer_category_view_tree"/>
+ <field name="act_window"
ref="act_reporting_customer_category_tree"/>
+ </record>
+ <record model="ir.action.keyword"
id="act_reporting_customer_category_tree_keyword1">
+ <field name="keyword">tree_open</field>
+ <field name="model" eval="'ir.ui.menu,%s' %
ref('menu_reporting_sale')"/>
+ <field name="action" ref="act_reporting_customer_category_tree"/>
+ </record>
+
+ <record model="ir.ui.view" id="reporting_customer_category_view_list">
+ <field name="model">sale.reporting.customer.category</field>
+ <field name="type">tree</field>
+ <field name="name">sale_reporting_customer_category_list</field>
+ </record>
+
+ <record model="ir.action.act_window"
id="act_reporting_customer_category">
+ <field name="name">Sales per Customer Category</field>
+ <field name="res_model">sale.reporting.customer.category</field>
+ <field name="context_model">sale.reporting.context</field>
+ <field
+ name="domain"
+ eval="[('category', 'child_of', Eval('active_ids', []),
'parent')]"
+ pyson="1"/>
+ </record>
+ <record model="ir.action.act_window.view"
id="act_reporting_customer_category_view1">
+ <field name="sequence" eval="10"/>
+ <field name="view" ref="reporting_customer_category_view_list"/>
+ <field name="act_window" ref="act_reporting_customer_category"/>
+ </record>
+ <record model="ir.action.keyword"
id="act_reporting_customer_category_keyword1">
+ <field name="keyword">tree_open</field>
+ <field
name="model">sale.reporting.customer.category.tree,-1</field>
+ <field name="action" ref="act_reporting_customer_category"/>
+ </record>
+
+ <record model="ir.rule.group"
id="rule_group_reporting_customer_category_companies">
+ <field name="name">User in companies</field>
+ <field name="model" search="[('model', '=',
'sale.reporting.customer.category')]"/>
+ <field name="global_p" eval="True"/>
+ </record>
+ <record model="ir.rule"
id="rule_reporting_customer_category_companies">
+ <field name="domain"
+ eval="[('company', 'in', Eval('companies', []))]"
+ pyson="1"/>
+ <field name="rule_group"
ref="rule_group_reporting_customer_category_companies"/>
+ </record>
+
+ <record model="ir.model.access"
id="access_reporting_customer_category">
+ <field name="model" search="[('model', '=',
'sale.reporting.customer.category')]"/>
+ <field name="perm_read" eval="False"/>
+ <field name="perm_write" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_delete" eval="False"/>
+ </record>
+ <record model="ir.model.access"
id="access_reporting_customer_category_sale">
+ <field name="model" search="[('model', '=',
'sale.reporting.customer.category')]"/>
+ <field name="group" ref="group_sale"/>
+ <field name="perm_read" eval="True"/>
+ <field name="perm_write" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_delete" eval="False"/>
+ </record>
+
+ <record model="ir.ui.view"
id="reporting_customer_category_time_series_view_list">
+ <field
name="model">sale.reporting.customer.category.time_series</field>
+ <field name="type">tree</field>
+ <field
name="name">sale_reporting_customer_category_time_series_list</field>
+ </record>
+
+ <record model="ir.ui.view"
id="reporting_customer_category_time_series_view_graph_revenue">
+ <field
name="model">sale.reporting.customer.category.time_series</field>
+ <field name="type">graph</field>
+ <field
name="name">sale_reporting_customer_category_time_series_graph_revenue</field>
+ </record>
+
+ <record model="ir.ui.view"
id="reporting_customer_category_time_series_view_graph_number">
+ <field
name="model">sale.reporting.customer.category.time_series</field>
+ <field name="type">graph</field>
+ <field
name="name">sale_reporting_customer_category_time_series_graph_number</field>
+ </record>
+
+ <record model="ir.action.act_window"
id="act_reporting_customer_category_time_series">
+ <field name="name">Sales per Party Category</field>
+ <field
name="res_model">sale.reporting.customer.category.time_series</field>
+ <field name="context_model">sale.reporting.context</field>
+ <field
+ name="domain"
+ eval="[('category', '=', Eval('active_id', -1))]"
+ pyson="1"/>
+ <field name="order" eval="[('date', 'DESC')]" pyson="1"/>
+ </record>
+ <record model="ir.action.act_window.view"
id="act_reporting_customer_category_time_series_list_view1">
+ <field name="sequence" eval="10"/>
+ <field name="view"
ref="reporting_customer_category_time_series_view_list"/>
+ <field name="act_window"
ref="act_reporting_customer_category_time_series"/>
+ </record>
+ <record model="ir.action.act_window.view"
id="act_reporting_customer_category_time_series_list_view2">
+ <field name="sequence" eval="20"/>
+ <field name="view"
ref="reporting_customer_category_time_series_view_graph_revenue"/>
+ <field name="act_window"
ref="act_reporting_customer_category_time_series"/>
+ </record>
+ <record model="ir.action.act_window.view"
id="act_reporting_customer_category_time_series_list_view3">
+ <field name="sequence" eval="30"/>
+ <field name="view"
ref="reporting_customer_category_time_series_view_graph_number"/>
+ <field name="act_window"
ref="act_reporting_customer_category_time_series"/>
+ </record>
+ <record model="ir.action.keyword"
id="act_reporting_customer_category_time_series_list_keyword1">
+ <field name="keyword">tree_open</field>
+ <field name="model">sale.reporting.customer.category,-1</field>
+ <field name="action"
ref="act_reporting_customer_category_time_series"/>
+ </record>
+
+ <record model="ir.rule.group"
id="rule_group_reporting_customer_category_time_series_companies">
+ <field name="name">User in companies</field>
+ <field name="model" search="[('model', '=',
'sale.reporting.customer.category.time_series')]"/>
+ <field name="global_p" eval="True"/>
+ </record>
+ <record model="ir.rule"
id="rule_reporting_customer_category_time_series_companies">
+ <field name="domain"
+ eval="[('company', 'in', Eval('companies', []))]"
+ pyson="1"/>
+ <field name="rule_group"
ref="rule_group_reporting_customer_category_time_series_companies"/>
+ </record>
+
+ <record model="ir.model.access"
id="access_reporting_customer_category_time_series">
+ <field name="model" search="[('model', '=',
'sale.reporting.customer.category.time_series')]"/>
+ <field name="perm_read" eval="False"/>
+ <field name="perm_write" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_delete" eval="False"/>
+ </record>
+ <record model="ir.model.access"
id="access_reporting_customer_category_time_series_sale">
+ <field name="model" search="[('model', '=',
'sale.reporting.customer.category.time_series')]"/>
+ <field name="group" ref="group_sale"/>
+ <field name="perm_read" eval="True"/>
+ <field name="perm_write" eval="False"/>
+ <field name="perm_create" eval="False"/>
+ <field name="perm_delete" eval="False"/>
+ </record>
+
<!-- Product -->
<record model="ir.ui.view" id="reporting_product_view_list">
diff -r 5ce863742e26 -r 95fda395767f tests/scenario_sale_reporting.rst
--- a/tests/scenario_sale_reporting.rst Sun Jul 04 17:54:47 2021 +0200
+++ b/tests/scenario_sale_reporting.rst Mon Jul 12 23:28:15 2021 +0200
@@ -51,15 +51,30 @@
... name="New York", code="US-NY", type='state', country=country_us)
>>> new_york.save()
+Create party categories::
+
+ >>> PartyCategory = Model.get('party.category')
+ >>> party_category_root1 = PartyCategory(name="Root1")
+ >>> party_category_root1.save()
+ >>> party_category_child1 = PartyCategory(name="Child1",
parent=party_category_root1)
+ >>> party_category_child1.save()
+ >>> party_category_child2 = PartyCategory(name="Child2",
parent=party_category_root1)
+ >>> party_category_child2.save()
+ >>> party_category_root2 = PartyCategory(name="Root2")
+ >>> party_category_root2.save()
+
Create parties::
>>> Party = Model.get('party.party')
>>> customer1 = Party(name='Customer1')
+ >>> customer1.categories.append(PartyCategory(party_category_child1.id))
+ >>> customer1.categories.append(PartyCategory(party_category_root2.id))
>>> address, = customer1.addresses
>>> address.country = country_us
>>> address.subdivision = california
>>> customer1.save()
>>> customer2 = Party(name='Customer2')
+ >>> customer2.categories.append(PartyCategory(party_category_child2.id))
>>> address, = customer2.addresses
>>> address.country = country_us
>>> address.subdivision = new_york
@@ -159,6 +174,43 @@
... (customer2.id, sale2.sale_date.replace(day=1), 1, Decimal('10'))])
True
+Check sale reporting per customer categories::
+
+ >>> CustomerCategory = Model.get('sale.reporting.customer.category')
+ >>> CustomerCategoryTimeseries = Model.get(
+ ... 'sale.reporting.customer.category.time_series')
+ >>> CustomerCategoryTree =
Model.get('sale.reporting.customer.category.tree')
+ >>> with config.set_context(context=context):
+ ... reports = CustomerCategory.find([])
+ ... time_series = CustomerCategoryTimeseries.find([])
+ ... tree = CustomerCategoryTree.find([])
+ >>> len(reports)
+ 3
+ >>> with config.set_context(context=context):
+ ... sorted((r.category.id, r.number, r.revenue) for r in reports) == \
+ ... sorted([(party_category_child1.id, 1, Decimal('30')),
+ ... (party_category_root2.id, 1, Decimal('30')),
+ ... (party_category_child2.id, 1, Decimal('10'))])
+ True
+ >>> len(time_series)
+ 3
+ >>> with config.set_context(context=context):
+ ... sorted((r.category.id, r.date, r.number, r.revenue)
+ ... for r in time_series) == sorted(
+ ... [(party_category_child1.id, sale1.sale_date.replace(day=1), 1,
Decimal('30')),
+ ... (party_category_root2.id, sale1.sale_date.replace(day=1), 1,
Decimal('30')),
+ ... (party_category_child2.id, sale2.sale_date.replace(day=1), 1,
Decimal('10'))])
+ True
+ >>> len(tree)
+ 4
+ >>> with config.set_context(context=context):
+ ... sorted((r.name, r.revenue) for r in tree) == sorted([
+ ... ('Root1', Decimal('40')),
+ ... ('Child1', Decimal('30')),
+ ... ('Child2', Decimal('10')),
+ ... ('Root2', Decimal('30'))])
+ True
+
Check sale reporting per product::
>>> Product = Model.get('sale.reporting.product')
diff -r 5ce863742e26 -r 95fda395767f
view/sale_reporting_customer_category_list.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/view/sale_reporting_customer_category_list.xml Mon Jul 12 23:28:15
2021 +0200
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree keyword_open="1">
+ <field name="category" expand="1"/>
+ <field name="number"/>
+ <field name="revenue" symbol="currency"/>
+ <field name="revenue_trend" expand="1"/>
+</tree>
diff -r 5ce863742e26 -r 95fda395767f
view/sale_reporting_customer_category_time_series_graph_number.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/view/sale_reporting_customer_category_time_series_graph_number.xml
Mon Jul 12 23:28:15 2021 +0200
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+ <x>
+ <field name="date"/>
+ </x>
+ <y>
+ <field name="number"/>
+ </y>
+</graph>
diff -r 5ce863742e26 -r 95fda395767f
view/sale_reporting_customer_category_time_series_graph_revenue.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/view/sale_reporting_customer_category_time_series_graph_revenue.xml
Mon Jul 12 23:28:15 2021 +0200
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+ <x>
+ <field name="date"/>
+ </x>
+ <y>
+ <field name="revenue"/>
+ </y>
+</graph>
diff -r 5ce863742e26 -r 95fda395767f
view/sale_reporting_customer_category_time_series_list.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/view/sale_reporting_customer_category_time_series_list.xml Mon Jul
12 23:28:15 2021 +0200
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree>
+ <field name="date"/>
+ <field name="number"/>
+ <field name="revenue" symbol="currency"/>
+</tree>
diff -r 5ce863742e26 -r 95fda395767f
view/sale_reporting_customer_category_tree.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/view/sale_reporting_customer_category_tree.xml Mon Jul 12 23:28:15
2021 +0200
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree keyword_open="1">
+ <field name="name" expand="1"/>
+ <field name="revenue"/>
+</tree>