diff --git a/CHANGELOG b/CHANGELOG
index daaacd5207..5e69e74b59 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -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 --git a/__init__.py b/__init__.py
index afbb97a81f..05fddd3eb9 100644
--- a/__init__.py
+++ b/__init__.py
@@ -37,6 +37,9 @@ def register():
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 --git a/sale_reporting.py b/sale_reporting.py
index 0a5cd88c99..a425365d58 100644
--- a/sale_reporting.py
+++ b/sale_reporting.py
@@ -321,6 +321,199 @@ class CustomerTimeseries(CustomerMixin, AbstractTimeseries, ModelView):
__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 --git a/sale_reporting.xml b/sale_reporting.xml
index fb601dec1b..86b5dc8a39 100644
--- a/sale_reporting.xml
+++ b/sale_reporting.xml
@@ -46,6 +46,10 @@ this repository contains the full copyright notices and license terms. -->
Sales per Customer
sale.reporting.customer
sale.reporting.context
+
@@ -67,6 +71,11 @@ this repository contains the full copyright notices and license terms. -->
+
+ tree_open
+ sale.reporting.customer.category.tree,-1
+
+
User in companies
@@ -173,6 +182,163 @@ this repository contains the full copyright notices and license terms. -->
+
+
+
+ sale.reporting.customer.category.tree
+ tree
+ children
+ sale_reporting_customer_category_tree
+
+
+
+ Sales per Customer Category
+ sale.reporting.customer.category.tree
+ sale.reporting.context
+
+
+
+
+
+
+
+
+ tree_open
+
+
+
+
+
+ sale.reporting.customer.category
+ tree
+ sale_reporting_customer_category_list
+
+
+
+ Sales per Customer Category
+ sale.reporting.customer.category
+ sale.reporting.context
+
+
+
+
+
+
+
+
+ tree_open
+ sale.reporting.customer.category.tree,-1
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.reporting.customer.category.time_series
+ tree
+ sale_reporting_customer_category_time_series_list
+
+
+
+ sale.reporting.customer.category.time_series
+ graph
+ sale_reporting_customer_category_time_series_graph_revenue
+
+
+
+ sale.reporting.customer.category.time_series
+ graph
+ sale_reporting_customer_category_time_series_graph_number
+
+
+
+ Sales per Party Category
+ sale.reporting.customer.category.time_series
+ sale.reporting.context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tree_open
+ sale.reporting.customer.category,-1
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/scenario_sale_reporting.rst b/tests/scenario_sale_reporting.rst
index 11eae34513..1edbccfb99 100644
--- a/tests/scenario_sale_reporting.rst
+++ b/tests/scenario_sale_reporting.rst
@@ -51,15 +51,30 @@ Create countries::
... 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 @@ Check sale reporting per customer::
... (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 --git a/view/sale_reporting_customer_category_list.xml b/view/sale_reporting_customer_category_list.xml
new file mode 100644
index 0000000000..e26866cd85
--- /dev/null
+++ b/view/sale_reporting_customer_category_list.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/view/sale_reporting_customer_category_time_series_graph_number.xml b/view/sale_reporting_customer_category_time_series_graph_number.xml
new file mode 100644
index 0000000000..93629fd87d
--- /dev/null
+++ b/view/sale_reporting_customer_category_time_series_graph_number.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/sale_reporting_customer_category_time_series_graph_revenue.xml b/view/sale_reporting_customer_category_time_series_graph_revenue.xml
new file mode 100644
index 0000000000..19db21a4f9
--- /dev/null
+++ b/view/sale_reporting_customer_category_time_series_graph_revenue.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/sale_reporting_customer_category_time_series_list.xml b/view/sale_reporting_customer_category_time_series_list.xml
new file mode 100644
index 0000000000..47be3fa189
--- /dev/null
+++ b/view/sale_reporting_customer_category_time_series_list.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/sale_reporting_customer_category_tree.xml b/view/sale_reporting_customer_category_tree.xml
new file mode 100644
index 0000000000..49afe263b1
--- /dev/null
+++ b/view/sale_reporting_customer_category_tree.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+