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 @@ + + + + + +