diff --git a/CHANGELOG b/CHANGELOG index 9372c0bbe2..6421b238a4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add reporting * Check pending sale to erase party * Add wizard to modify sale header after filling lines * Make description on sale line optional diff --git a/INSTALL b/INSTALL index 9055b0cccf..fd2f71df75 100644 --- a/INSTALL +++ b/INSTALL @@ -6,6 +6,7 @@ Prerequisites * Python 2.7 or later (http://www.python.org/) * trytond (http://www.tryton.org/) + * python-dateutil (http://labix.org/python-dateutil) * python-sql 0.4 or later (http://code.google.com/p/python-sql/) * trytond_company (http://www.tryton.org/) * trytond_party (http://www.tryton.org/) @@ -16,6 +17,7 @@ Prerequisites * trytond_account_invoice_stock (http://www.tryton.org/) * trytond_currency (http://www.tryton.org/) * trytond_account_product (http://www.tryton.org/) + * Optional: pygal (http://pygal.org/) Installation ------------ diff --git a/__init__.py b/__init__.py index 02545439ba..b5d66458ac 100644 --- a/__init__.py +++ b/__init__.py @@ -8,6 +8,7 @@ from .stock import * from .configuration import * from .invoice import * from . import party +from . import sale_reporting def register(): @@ -30,6 +31,19 @@ def register(): Configuration, ConfigurationSequence, ConfigurationSaleMethod, + sale_reporting.Context, + sale_reporting.Customer, + sale_reporting.CustomerTimeseries, + sale_reporting.Product, + sale_reporting.ProductTimeseries, + sale_reporting.Category, + sale_reporting.CategoryTimeseries, + sale_reporting.CategoryTree, + sale_reporting.Country, + sale_reporting.CountryTimeseries, + sale_reporting.Subdivision, + sale_reporting.SubdivisionTimeseries, + sale_reporting.Region, Invoice, InvoiceLine, module='sale', type_='model') @@ -41,6 +55,7 @@ def register(): ModifyHeader, party.PartyReplace, party.PartyErase, + sale_reporting.OpenRegion, module='sale', type_='wizard') Pool.register( SaleReport, diff --git a/sale_reporting.py b/sale_reporting.py new file mode 100644 index 0000000000..8de7378389 --- /dev/null +++ b/sale_reporting.py @@ -0,0 +1,697 @@ +# 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 collections import defaultdict +from itertools import tee, izip_longest + +try: + import pygal +except ImportError: + pygal = None +from dateutil.relativedelta import relativedelta +from sql import Null, Literal, Cast, Column +from sql.aggregate import Sum, Min, Count +from sql.conditionals import Case +from sql.functions import CurrentTimestamp, DateTrunc + +from trytond import backend +from trytond.pool import Pool +from trytond.model import ModelSQL, ModelView, UnionMixin, fields +from trytond.tools import grouped_slice, reduce_ids +from trytond.transaction import Transaction +from trytond.pyson import Eval, If +from trytond.wizard import Wizard, StateTransition, StateAction + +__all__ = ['Context', + 'Customer', 'CustomerTimeseries', + 'Product', 'ProductTimeseries', + 'Category', 'CategoryTimeseries', 'CategoryTree', + 'Country', 'CountryTimeseries', + 'Subdivision', 'SubdivisionTimeseries', + 'Region', 'OpenRegion'] + + +def pairwise(iterable): + a, b = tee(iterable) + b.next() + return izip_longest(a, b) + + +class Abstract(ModelSQL): + + company = fields.Many2One('company.company', "Company") + number = fields.Integer("#", help="Number of sales") + revenue = fields.Numeric( + "Revenue", digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']) + revenue_trend = fields.Function( + fields.Char("Revenue Trend"), 'get_trend') + time_series = None + + currency_digits = fields.Function( + fields.Integer("Currency Digits"), 'get_currency_digits') + + @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() + Company = pool.get('company.company') + Currency = pool.get('currency.currency') + Line = pool.get('sale.line') + Sale = pool.get('sale.sale') + + tables = {} + tables['line'] = line = Line.__table__() + tables['line.sale'] = sale = Sale.__table__() + tables['line.sale.company'] = company = Company.__table__() + currency_sale = Currency.currency_rate_sql() + tables['currency_sale'] = currency_sale + currency_company = Currency.currency_rate_sql() + tables['currency_company'] = currency_company + + from_item = (line + .join(sale, condition=line.sale == sale.id) + .join(currency_sale, + condition=(sale.currency == currency_sale.currency) + & (currency_sale.start_date <= sale.sale_date) + & ((currency_sale.end_date == Null) + | (currency_sale.end_date >= sale.sale_date)) + ) + .join(company, condition=sale.company == company.id) + .join(currency_company, + condition=(company.currency == currency_company.currency) + & (currency_company.start_date <= sale.sale_date) + & ((currency_company.end_date == Null) + | (currency_company.end_date >= sale.sale_date)) + )) + return from_item, tables + + @classmethod + def _columns(cls, tables): + line = tables['line'] + sale = tables['line.sale'] + currency_company = tables['currency_company'] + currency_sale = tables['currency_sale'] + + revenue = Cast( + Sum(line.quantity * line.unit_price + * currency_company.rate / currency_sale.rate), + cls.revenue.sql_type().base) + return [ + cls._column_id(tables).as_('id'), + Literal(0).as_('create_uid'), + CurrentTimestamp().as_('create_date'), + Cast(Literal(Null), cls.write_uid.sql_type().base + ).as_('write_uid'), + Cast(Literal(Null), cls.write_date.sql_type().base + ).as_('write_date'), + sale.company.as_('company'), + revenue.as_('revenue'), + Count(sale.id, distinct=True).as_('number'), + ] + + @classmethod + def _column_id(cls, tables): + line = tables['line'] + return Min(line.id) + + @classmethod + def _group_by(cls, tables): + sale = tables['line.sale'] + return [sale.company] + + @classmethod + def _where(cls, tables): + context = Transaction().context + sale = tables['line.sale'] + + where = sale.company == context.get('company') + where &= sale.state.in_(cls._sale_states()) + from_date = context.get('from_date') + if from_date: + where &= sale.sale_date >= from_date + to_date = context.get('to_date') + if to_date: + where &= sale.sale_date <= to_date + warehouse = context.get('warehouse') + if warehouse: + where &= sale.warehouse == warehouse + return where + + @classmethod + def _sale_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 None + for ts in self.time_series_all]) + return chart.render_sparktext() + + def get_currency_digits(self, name): + return self.company.currency.digits + + +class AbstractTimeseries(Abstract): + + date = fields.Date("Date") + + @classmethod + def __setup__(cls): + super(AbstractTimeseries, cls).__setup__() + cls._order = [('date', 'ASC')] + + @classmethod + def _columns(cls, tables): + return super(AbstractTimeseries, cls)._columns(tables) + [ + cls._column_date(tables).as_('date')] + + @classmethod + def _column_date(cls, tables): + context = Transaction().context + sale = tables['line.sale'] + date = DateTrunc(context.get('period'), sale.sale_date) + if backend.name() != 'sqlite': + date = Cast(date, cls.date.sql_type().base) + return date + + @classmethod + def _group_by(cls, tables): + return super(AbstractTimeseries, cls)._group_by(tables) + [ + cls._column_date(tables)] + + +class Context(ModelView): + "Sale Reporting Context" + __name__ = 'sale.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 CustomerMixin(object): + customer = fields.Many2One('party.party', "Customer") + + @classmethod + def _columns(cls, tables): + sale = tables['line.sale'] + return super(CustomerMixin, cls)._columns(tables) + [ + sale.party.as_('customer')] + + @classmethod + def _group_by(cls, tables): + sale = tables['line.sale'] + return super(CustomerMixin, cls)._group_by(tables) + [sale.party] + + def get_rec_name(self, name): + return self.customer.rec_name + + +class Customer(CustomerMixin, Abstract, ModelView): + "Sale Reporting per Customer" + __name__ = 'sale.reporting.customer' + + time_series = fields.One2Many( + 'sale.reporting.customer.time_series', 'customer', "Time Series") + + @classmethod + def _column_id(cls, tables): + sale = tables['line.sale'] + return sale.party + + +class CustomerTimeseries(CustomerMixin, AbstractTimeseries, ModelView): + "Sale Reporting per Customer" + __name__ = 'sale.reporting.customer.time_series' + + +class ProductMixin(object): + product = fields.Many2One('product.product', "Product") + + @classmethod + def _columns(cls, tables): + line = tables['line'] + return super(ProductMixin, cls)._columns(tables) + [ + line.product.as_('product')] + + @classmethod + def _group_by(cls, tables): + line = tables['line'] + return super(ProductMixin, cls)._group_by(tables) + [line.product] + + @classmethod + def _where(cls, tables): + line = tables['line'] + where = super(ProductMixin, cls)._where(tables) + where &= line.product != Null + return where + + def get_rec_name(self, name): + return self.product.rec_name if self.product else None + + +class Product(ProductMixin, Abstract, ModelView): + "Sale Reporting per Product" + __name__ = 'sale.reporting.product' + + time_series = fields.One2Many( + 'sale.reporting.product.time_series', 'product', "Time Series") + + @classmethod + def _column_id(cls, tables): + line = tables['line'] + return line.product + + +class ProductTimeseries(ProductMixin, AbstractTimeseries, ModelView): + "Sale Reporting per Product" + __name__ = 'sale.reporting.product.time_series' + + +class CategoryMixin(object): + category = fields.Many2One('product.category', "Category") + + @classmethod + def _joins(cls): + pool = Pool() + Product = pool.get('product.product') + TemplateCategory = pool.get('product.template-product.category.all') + from_item, tables = super(CategoryMixin, cls)._joins() + if 'line.product' not in tables: + product = Product.__table__() + tables['line.product'] = product + line = tables['line'] + from_item = (from_item + .join(product, condition=line.product == product.id)) + if 'line.product.template_category' not in tables: + template_category = TemplateCategory.__table__() + tables['line.product.template_category'] = template_category + product = tables['line.product'] + from_item = (from_item + .join(template_category, + condition=product.template == template_category.template)) + return from_item, tables + + @classmethod + def _columns(cls, tables): + template_category = tables['line.product.template_category'] + return super(CategoryMixin, cls)._columns(tables) + [ + template_category.category.as_('category')] + + @classmethod + def _column_id(cls, tables): + line = tables['line'] + template_category = tables['line.product.template_category'] + # Pairing function from http://szudzik.com/ElegantPairing.pdf + return Min(Case( + (line.id < template_category.id, + (template_category.id * template_category.id) + line.id), + else_=(line.id * line.id) + line.id + template_category.id)) + + @classmethod + def _group_by(cls, tables): + template_category = tables['line.product.template_category'] + return super(CategoryMixin, cls)._group_by(tables) + [ + template_category.category] + + @classmethod + def _where(cls, tables): + template_category = tables['line.product.template_category'] + where = super(CategoryMixin, cls)._where(tables) + where &= template_category.category != Null + return where + + def get_rec_name(self, name): + return self.category.rec_name if self.category else None + + +class Category(CategoryMixin, Abstract, ModelView): + "Sale Reporting per Category" + __name__ = 'sale.reporting.category' + + time_series = fields.One2Many( + 'sale.reporting.category.time_series', 'category', "Time Series") + + @classmethod + def _column_id(cls, tables): + template_category = tables['line.product.template_category'] + return template_category.category + + +class CategoryTimeseries(CategoryMixin, AbstractTimeseries, ModelView): + "Sale Reporting per Category" + __name__ = 'sale.reporting.category.time_series' + + +class CategoryTree(ModelSQL, ModelView): + "Sale Reporting per Category" + __name__ = 'sale.reporting.category.tree' + + name = fields.Function(fields.Char("Name"), 'get_name') + parent = fields.Many2One('sale.reporting.category.tree', "Parent") + children = fields.One2Many( + 'sale.reporting.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 table_query(cls): + pool = Pool() + Category = pool.get('product.category') + return Category.__table__() + + @classmethod + def get_name(cls, categories, name): + pool = Pool() + Category = pool.get('product.category') + categories = Category.browse(categories) + return {c.id: c.name for c in categories} + + def time_series_all(self): + return [] + + @classmethod + def get_total(cls, categories, names): + pool = Pool() + ReportingCategory = pool.get('sale.reporting.category') + table = cls.__table__() + reporting_category = ReportingCategory.__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_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.fetchall()) + + where = reduce_ids(reporting_category.id, sub_ids) + cursor.execute( + *reporting_category.select(reporting_category.id, where=where)) + reporting_categories.extend(r for r, in cursor.fetchall()) + + result = {} + reporting_categories = ReportingCategory.browse(reporting_categories) + for name in names: + values = dict.fromkeys(ids, 0) + values.update( + (c.id, getattr(c, name)) for c in reporting_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.itervalues()) + 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 CountryMixin(object): + + country = fields.Many2One('country.country', "Country") + + @classmethod + def _joins(cls): + pool = Pool() + Address = pool.get('party.address') + from_item, tables = super(CountryMixin, cls)._joins() + if 'line.sale.shipment_address' not in tables: + address = Address.__table__() + tables['line.sale.shipment_address'] = address + sale = tables['line.sale'] + from_item = (from_item + .join(address, condition=sale.shipment_address == address.id)) + return from_item, tables + + @classmethod + def _columns(cls, tables): + address = tables['line.sale.shipment_address'] + return super(CountryMixin, cls)._columns(tables) + [ + address.country.as_('country')] + + @classmethod + def _group_by(cls, tables): + address = tables['line.sale.shipment_address'] + return super(CountryMixin, cls)._group_by(tables) + [address.country] + + @classmethod + def _where(cls, tables): + address = tables['line.sale.shipment_address'] + where = super(CountryMixin, cls)._where(tables) + where &= address.country != Null + return where + + +class Country(CountryMixin, Abstract): + "Sale Reporting per Country" + __name__ = 'sale.reporting.country' + + time_series = fields.One2Many( + 'sale.reporting.country.time_series', 'country', "Time Series") + + @classmethod + def _column_id(cls, tables): + address = tables['line.sale.shipment_address'] + return address.country + + def get_rec_name(self, name): + return self.country.rec_name + + +class CountryTimeseries(CountryMixin, AbstractTimeseries, ModelView): + "Sale Reporting per Country" + __name__ = 'sale.reporting.country.time_series' + + +class SubdivisionMixin(CountryMixin): + + subdivision = fields.Many2One('country.subdivision', "Subdivision") + + @classmethod + def _columns(cls, tables): + address = tables['line.sale.shipment_address'] + return super(SubdivisionMixin, cls)._columns(tables) + [ + address.subdivision.as_('subdivision')] + + @classmethod + def _group_by(cls, tables): + address = tables['line.sale.shipment_address'] + return super(SubdivisionMixin, cls)._group_by(tables) + [ + address.subdivision] + + @classmethod + def _where(cls, tables): + address = tables['line.sale.shipment_address'] + where = super(SubdivisionMixin, cls)._where(tables) + where &= address.subdivision != Null + return where + + +class Subdivision(SubdivisionMixin, Abstract): + "Sale Reporting per Subdivision" + __name__ = 'sale.reporting.country.subdivision' + + time_series = fields.One2Many( + 'sale.reporting.country.subdivision.time_series', 'subdivision', + "Time Series") + + @classmethod + def _column_id(cls, tables): + address = tables['line.sale.shipment_address'] + return address.subdivision + + def get_rec_name(self, name): + return self.subdivision.rec_name + + +class SubdivisionTimeseries(SubdivisionMixin, AbstractTimeseries, ModelView): + "Sale Reporting per Subdivision" + __name__ = 'sale.reporting.country.subdivision.time_series' + + +class Region(UnionMixin, Abstract, ModelView): + "Sale Reporting per Region" + __name__ = 'sale.reporting.region' + + region = fields.Function(fields.Char("Region"), 'get_rec_name') + parent = fields.Many2One('sale.reporting.region', "Parent") + children = fields.One2Many('sale.reporting.region', 'parent', "Children") + + @classmethod + def union_models(cls): + return ['sale.reporting.country', 'sale.reporting.country.subdivision'] + + @classmethod + def union_column(cls, name, field, table, Model): + column = super(Region, cls).union_column( + name, field, table, Model) + if (name == 'parent' + and Model.__name__ == 'sale.reporting.country.subdivision'): + column = cls.union_shard( + Column(table, 'country'), 'sale.reporting.country') + return column + + @classmethod + def get_rec_name(cls, records, name): + names = {} + classes = defaultdict(list) + for record in records: + record = cls.union_unshard(record.id) + classes[record.__class__].append(record) + for klass, records in classes.iteritems(): + for record in klass.browse(records): + names[cls.union_shard(record.id, klass.__name__)] = ( + record.rec_name) + return names + + @property + def time_series(self): + record = self.union_unshard(self.id) + return record.time_series + + +class OpenRegion(Wizard): + "Open Region" + __name__ = 'sale.reporting.region.open' + + start = StateTransition() + country = StateAction('sale.act_reporting_country_time_series') + subdivision = StateAction( + 'sale.act_reporting_country_subdivision_time_series') + + def transition_start(self): + pool = Pool() + Region = pool.get('sale.reporting.region') + Country = pool.get('sale.reporting.country') + Subdivision = pool.get('sale.reporting.country.subdivision') + region = Region(Transaction().context['active_id']) + record = Region.union_unshard(region.id) + if isinstance(record, Country): + return 'country' + elif isinstance(record, Subdivision): + return 'subdivision' + + def _do_action(self, action): + pool = Pool() + Region = pool.get('sale.reporting.region') + region = Region(Transaction().context['active_id']) + record = Region.union_unshard(region.id) + data = { + 'id': record.id, + 'ids': [record.id], + } + action['name'] += ' (%s)' % record.rec_name + return action, data + do_country = _do_action + do_subdivision = _do_action diff --git a/sale_reporting.xml b/sale_reporting.xml new file mode 100644 index 0000000000..950f8654af --- /dev/null +++ b/sale_reporting.xml @@ -0,0 +1,572 @@ + + + + + + + + + + sale.reporting.context + form + sale_reporting_context_form + + + + + + sale.reporting.customer + tree + sale_reporting_customer_list + + + + sale.reporting.customer + graph + sale_reporting_customer_graph_revenue + + + + sale.reporting.customer + graph + sale_reporting_customer_graph_number + + + + Sales per Customer + sale.reporting.customer + sale.reporting.context + + + + + + + + + + + + + + + + + + tree_open + + + + + + + + + + + + + + + + + + + + + + sale.reporting.customer.time_series + tree + sale_reporting_customer_time_series_list + + + + sale.reporting.customer.time_series + graph + sale_reporting_customer_time_series_graph_revenue + + + + sale.reporting.customer.time_series + graph + sale_reporting_customer_time_series_graph_number + + + + Sales per Customer + sale.reporting.customer.time_series + sale.reporting.context + + + + + + + + + + + + + + + + + + + tree_open + sale.reporting.customer,-1 + + + + + + + + + + + + + + + + + + + + + + + sale.reporting.product + tree + sale_reporting_product_list + + + + sale.reporting.product + graph + sale_reporting_product_graph_revenue + + + + sale.reporting.product + graph + sale_reporting_product_graph_number + + + + Sales per Product + sale.reporting.product + sale.reporting.context + + + + + + + + + + + + + + + + + + tree_open + + + + + + + + + + + + + + + + + + + + + + sale.reporting.product.time_series + tree + sale_reporting_product_time_series_list + + + + sale.reporting.product.time_series + graph + sale_reporting_product_time_series_graph_revenue + + + + sale.reporting.product.time_series + graph + sale_reporting_product_time_series_graph_number + + + + Sales per Product + sale.reporting.product.time_series + sale.reporting.context + + + + + + + + + + + + + + + + + + + tree_open + sale.reporting.product,-1 + + + + + + + + + + + + + + + + + + + + + + + sale.reporting.category.tree + tree + children + sale_reporting_category_tree + + + + Sales per Category + sale.reporting.category.tree + sale.reporting.context + + + + + + + + + tree_open + + + + + + sale.reporting.category + tree + sale_reporting_category_list + + + + Sales per Category + sale.reporting.category + sale.reporting.context + + + + + + + + + tree_open + sale.reporting.category.tree,-1 + + + + + + + + + + + + + + + + + + + + + sale.reporting.category.time_series + tree + sale_reporting_category_time_series_list + + + + sale.reporting.category.time_series + graph + sale_reporting_category_time_series_graph_revenue + + + + sale.reporting.category.time_series + graph + sale_reporting_category_time_series_graph_number + + + + Sales per Category + sale.reporting.category.time_series + sale.reporting.context + + + + + + + + + + + + + + + + + + + tree_open + sale.reporting.category,-1 + + + + + + + + + + + + + + + + + + + + + + + sale.reporting.region + tree + sale_reporting_region_tree + children + + + + Sales per Region + sale.reporting.region + sale.reporting.context + + + + + + + + + tree_open + + + + + + + + + + + + + + + + + + + + + + sale.reporting.country.time_series + tree + sale_reporting_country_time_series_list + + + + sale.reporting.country.time_series + graph + sale_reporting_country_time_series_graph_revenue + + + + sale.reporting.country.time_series + graph + sale_reporting_country_time_series_graph_number + + + + Sales per Country + sale.reporting.country.time_series + sale.reporting.context + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sale.reporting.country.subdivision.time_series + tree + sale_reporting_country_subdivision_time_series_list + + + + sale.reporting.country.subdivision.time_series + graph + sale_reporting_country_subdivision_time_series_graph_revenue + + + + sale.reporting.country.subdivision.time_series + graph + sale_reporting_country_subdivision_time_series_graph_number + + + + Sales per Subdivision + sale.reporting.country.subdivision.time_series + sale.reporting.context + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Open Region + sale.reporting.region.open + sale.reporting.region + + + tree_open + sale.reporting.region,-1 + + + + + diff --git a/setup.py b/setup.py index bcdbaec5b2..9e46795da0 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ if minor_version % 2: 'hg+http://hg.tryton.org/modules/%s#egg=%s-%s' % ( name[8:], name, version)) -requires = ['python-sql >= 0.4'] +requires = ['python-sql >= 0.4', 'python-dateutil'] for dep in info.get('depends', []): if not re.match(r'(ir|res)(\W|$)', dep): requires.append(get_require_version('trytond_%s' % dep)) @@ -112,6 +112,9 @@ setup(name=name, ], license='GPL-3', install_requires=requires, + extras_require={ + 'sparklines': ['pygal'], + }, dependency_links=dependency_links, zip_safe=False, entry_points=""" @@ -126,5 +129,6 @@ setup(name=name, 'tests/scenario_sale.rst', 'tests/scenario_sale_empty.rst', 'tests/scenario_sale_ship_other_product.rst', + 'tests/scenario_sale_reporting.rst', ], ) diff --git a/tests/scenario_sale_reporting.rst b/tests/scenario_sale_reporting.rst new file mode 100644 index 0000000000..b795faba52 --- /dev/null +++ b/tests/scenario_sale_reporting.rst @@ -0,0 +1,252 @@ +======================= +Sale 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 + +Install sale:: + + >>> config = activate_modules('sale') + +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) + >>> revenue = accounts['revenue'] + >>> expense = accounts['expense'] + +Create countries:: + + >>> Country = Model.get('country.country') + >>> Subdivision = Model.get('country.subdivision') + >>> country_us = Country( + ... name="United States", code="US", code3="USA", code_numeric="840") + >>> country_us.save() + >>> california = Subdivision( + ... name="California", code="US-CA", type='state', country=country_us) + >>> california.save() + >>> new_york = Subdivision( + ... name="New York", code="US-NY", type='state', country=country_us) + >>> new_york.save() + +Create parties:: + + >>> Party = Model.get('party.party') + >>> customer1 = Party(name='Customer1') + >>> address, = customer1.addresses + >>> address.country = country_us + >>> address.subdivision = california + >>> customer1.save() + >>> customer2 = Party(name='Customer2') + >>> address, = customer2.addresses + >>> address.country = country_us + >>> address.subdivision = new_york + >>> customer2.save() + +Create products:: + + >>> ProductUom = Model.get('product.uom') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + >>> ProductTemplate = Model.get('product.template') + >>> Category = Model.get('product.category') + + >>> template1 = ProductTemplate() + >>> template1.name = "Product1" + >>> template1.default_uom = unit + >>> template1.type = 'service' + >>> template1.salable = True + >>> template1.list_price = Decimal('10') + >>> template1.account_revenue = revenue + >>> template1.save() + >>> product1, = template1.products + + >>> template2, = template1.duplicate(default={'name': "Product2"}) + >>> product2, = template2.products + + >>> category_root1 = Category(name="Root1") + >>> category_root1.save() + >>> category_child1 = Category(name="Child1", parent=category_root1) + >>> category_child1.save() + >>> category_child2 = Category(name="Child2", parent=category_root1) + >>> category_child2.save() + >>> category_root2 = Category(name="Root2") + >>> category_root2.save() + + >>> template1.categories.append(Category(category_child1.id)) + >>> template1.categories.append(Category(category_root2.id)) + >>> template1.save() + >>> template2.categories.append(Category(category_child2.id)) + >>> template2.save() + +Create sales:: + + >>> Sale = Model.get('sale.sale') + + >>> sale1 = Sale() + >>> sale1.party = customer1 + >>> sale1.sale_date = fiscalyear.start_date + >>> line = sale1.lines.new() + >>> line.product = product1 + >>> line.quantity = 2 + >>> line = sale1.lines.new() + >>> line.product = product2 + >>> line.quantity = 1 + >>> sale1.click('quote') + >>> sale1.click('confirm') + + >>> sale2 = Sale() + >>> sale2.party = customer2 + >>> sale2.sale_date = fiscalyear.start_date + relativedelta(months=1) + >>> line = sale2.lines.new() + >>> line.product = product1 + >>> line.quantity = 1 + >>> sale2.click('quote') + >>> sale2.click('confirm') + +Check sale reporting per customer:: + + >>> Customer = Model.get('sale.reporting.customer') + >>> CustomerTimeseries = Model.get('sale.reporting.customer.time_series') + >>> with config.set_context( + ... from_date=fiscalyear.start_date, + ... to_date=fiscalyear.end_date, + ... period='day', + ... ): + ... reports = Customer.find([]) + ... time_series = CustomerTimeseries.find([]) + >>> len(reports) + 2 + >>> sorted((r.customer.id, r.number, r.revenue) for r in reports) == \ + ... sorted([(customer1.id, 1, Decimal('30')), + ... (customer2.id, 1, Decimal('10'))]) + True + >>> len(time_series) + 2 + >>> sorted((r.customer.id, str(r.date), r.number, r.revenue) + ... for r in time_series) == sorted( + ... [(customer1.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('30')), + ... (customer2.id, str(sale2.sale_date.replace(day=1)), 1, Decimal('10'))]) + True + +Check sale reporting per product:: + + >>> Product = Model.get('sale.reporting.product') + >>> ProductTimeseries = Model.get('sale.reporting.product.time_series') + >>> with config.set_context( + ... from_date=fiscalyear.start_date, + ... to_date=fiscalyear.end_date, + ... period='day', + ... ): + ... reports = Product.find([]) + ... time_series = ProductTimeseries.find([]) + >>> len(reports) + 2 + >>> sorted((r.product.id, r.number, r.revenue) for r in reports) == \ + ... sorted([(product1.id, 2, Decimal('30')), + ... (product2.id, 1, Decimal('10'))]) + True + >>> len(time_series) + 3 + >>> sorted((r.product.id, str(r.date), r.number, r.revenue) + ... for r in time_series) == sorted( + ... [(product1.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('20')), + ... (product2.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('10')), + ... (product1.id, str(sale2.sale_date.replace(day=1)), 1, Decimal('10'))]) + True + +Check sale reporting per categories:: + + >>> Category = Model.get('sale.reporting.category') + >>> CategoryTimeseries = Model.get('sale.reporting.category.time_series') + >>> CategoryTree = Model.get('sale.reporting.category.tree') + >>> with config.set_context( + ... from_date=fiscalyear.start_date, + ... to_date=fiscalyear.end_date, + ... period='day', + ... ): + ... reports = Category.find([]) + ... time_series = CategoryTimeseries.find([]) + ... tree = CategoryTree.find([]) + >>> len(reports) + 3 + >>> sorted((r.category.id, r.number, r.revenue) for r in reports) == \ + ... sorted([(category_child1.id, 2, Decimal('30')), + ... (category_root2.id, 2, Decimal('30')), + ... (category_child2.id, 1, Decimal('10'))]) + True + >>> len(time_series) + 5 + >>> sorted((r.category.id, str(r.date), r.number, r.revenue) + ... for r in time_series) == sorted( + ... [(category_child1.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('20')), + ... (category_root2.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('20')), + ... (category_child2.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('10')), + ... (category_child1.id, str(sale2.sale_date.replace(day=1)), 1, Decimal('10')), + ... (category_root2.id, str(sale2.sale_date.replace(day=1)), 1, Decimal('10'))]) + True + >>> len(tree) + 4 + >>> sorted((r.name, r.revenue) for r in tree) == sorted([ + ... (u'Root1', Decimal('40')), + ... (u'Child1', Decimal('30')), + ... (u'Child2', Decimal('10')), + ... (u'Root2', Decimal('30'))]) + True + +Check sale reporting per regions:: + + >>> Region = Model.get('sale.reporting.region') + >>> CountryTimeseries = Model.get('sale.reporting.country.time_series') + >>> SubdivisionTimeseries = Model.get( + ... 'sale.reporting.country.subdivision.time_series') + >>> with config.set_context( + ... from_date=fiscalyear.start_date, + ... to_date=fiscalyear.end_date, + ... period='day', + ... ): + ... reports = Region.find([]) + ... country_time_series = CountryTimeseries.find([]) + ... subdivision_time_series = SubdivisionTimeseries.find([]) + >>> len(reports) + 3 + >>> sorted((r.region, r.number, r.revenue) for r in reports) == \ + ... sorted([('United States', 2, Decimal('40')), + ... ('California', 1, Decimal('30')), + ... ('New York', 1, Decimal('10'))]) + True + >>> len(country_time_series) + 2 + >>> sorted((r.country.id, str(r.date), r.number, r.revenue) + ... for r in country_time_series) == sorted( + ... [(country_us.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('30')), + ... (country_us.id, str(sale2.sale_date.replace(day=1)), 1, Decimal('10'))]) + True + >>> len(subdivision_time_series) + 2 + >>> sorted((r.subdivision.id, str(r.date), r.number, r.revenue) + ... for r in subdivision_time_series) == sorted( + ... [(california.id, str(sale1.sale_date.replace(day=1)), 1, Decimal('30')), + ... (new_york.id, str(sale2.sale_date.replace(day=1)), 1, Decimal('10'))]) + True diff --git a/tests/test_sale.py b/tests/test_sale.py index bc4888dabd..e3183433c1 100644 --- a/tests/test_sale.py +++ b/tests/test_sale.py @@ -33,4 +33,9 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', optionflags=doctest.REPORT_ONLY_FIRST_FAILURE, checker=doctest_checker)) + suite.addTests(doctest.DocFileSuite( + 'scenario_sale_reporting.rst', + tearDown=doctest_teardown, encoding='utf-8', + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE, + checker=doctest_checker)) return suite diff --git a/tryton.cfg b/tryton.cfg index 7e283df670..05c490e39a 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -15,6 +15,7 @@ depends: xml: sale.xml configuration.xml + sale_reporting.xml party.xml stock.xml product.xml diff --git a/view/sale_reporting_category_list.xml b/view/sale_reporting_category_list.xml new file mode 100644 index 0000000000..423416e9ee --- /dev/null +++ b/view/sale_reporting_category_list.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/view/sale_reporting_category_time_series_graph_number.xml b/view/sale_reporting_category_time_series_graph_number.xml new file mode 100644 index 0000000000..93629fd87d --- /dev/null +++ b/view/sale_reporting_category_time_series_graph_number.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/view/sale_reporting_category_time_series_graph_revenue.xml b/view/sale_reporting_category_time_series_graph_revenue.xml new file mode 100644 index 0000000000..19db21a4f9 --- /dev/null +++ b/view/sale_reporting_category_time_series_graph_revenue.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/view/sale_reporting_category_time_series_list.xml b/view/sale_reporting_category_time_series_list.xml new file mode 100644 index 0000000000..b717a1c85d --- /dev/null +++ b/view/sale_reporting_category_time_series_list.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/view/sale_reporting_category_tree.xml b/view/sale_reporting_category_tree.xml new file mode 100644 index 0000000000..5b5d1c529a --- /dev/null +++ b/view/sale_reporting_category_tree.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/view/sale_reporting_context_form.xml b/view/sale_reporting_context_form.xml new file mode 100644 index 0000000000..c410145021 --- /dev/null +++ b/view/sale_reporting_context_form.xml @@ -0,0 +1,17 @@ + + +
+ + +