From acfcbff597aa19269b8627efde18fecee2faf501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 17 Jan 2021 00:57:09 +0100 Subject: [PATCH] Add margin reporting issue9665 review292371002 --- CHANGELOG | 1 + __init__.py | 7 + message.xml | 24 + setup.py | 3 + stock_reporting_margin.py | 629 ++++++++++++++++++ stock_reporting_margin.xml | 360 ++++++++++ tests/scenario_stock_reporting_margin.rst | 215 ++++++ tests/test_stock.py | 5 + tryton.cfg | 1 + ...reporting_margin_category_graph_amount.xml | 12 + ...reporting_margin_category_graph_margin.xml | 11 + ...reporting_margin_category_graph_profit.xml | 11 + view/reporting_margin_category_list.xml | 13 + ...rgin_category_time_series_graph_amount.xml | 12 + ...rgin_category_time_series_graph_margin.xml | 11 + ...rgin_category_time_series_graph_profit.xml | 11 + ...rting_margin_category_time_series_list.xml | 12 + view/reporting_margin_category_tree.xml | 12 + view/reporting_margin_context_form.xml | 20 + .../reporting_margin_product_graph_amount.xml | 12 + .../reporting_margin_product_graph_margin.xml | 11 + .../reporting_margin_product_graph_profit.xml | 11 + view/reporting_margin_product_list.xml | 14 + ...argin_product_time_series_graph_amount.xml | 12 + ...argin_product_time_series_graph_margin.xml | 11 + ...argin_product_time_series_graph_profit.xml | 11 + ...orting_margin_product_time_series_list.xml | 13 + 27 files changed, 1465 insertions(+) create mode 100644 stock_reporting_margin.py create mode 100644 stock_reporting_margin.xml create mode 100644 tests/scenario_stock_reporting_margin.rst create mode 100644 view/reporting_margin_category_graph_amount.xml create mode 100644 view/reporting_margin_category_graph_margin.xml create mode 100644 view/reporting_margin_category_graph_profit.xml create mode 100644 view/reporting_margin_category_list.xml create mode 100644 view/reporting_margin_category_time_series_graph_amount.xml create mode 100644 view/reporting_margin_category_time_series_graph_margin.xml create mode 100644 view/reporting_margin_category_time_series_graph_profit.xml create mode 100644 view/reporting_margin_category_time_series_list.xml create mode 100644 view/reporting_margin_category_tree.xml create mode 100644 view/reporting_margin_context_form.xml create mode 100644 view/reporting_margin_product_graph_amount.xml create mode 100644 view/reporting_margin_product_graph_margin.xml create mode 100644 view/reporting_margin_product_graph_profit.xml create mode 100644 view/reporting_margin_product_list.xml create mode 100644 view/reporting_margin_product_time_series_graph_amount.xml create mode 100644 view/reporting_margin_product_time_series_graph_margin.xml create mode 100644 view/reporting_margin_product_time_series_graph_profit.xml create mode 100644 view/reporting_margin_product_time_series_list.xml diff --git a/CHANGELOG b/CHANGELOG index dbaaa4cefa..15713c8a7b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add margin reporting * Add products_by_locations Model Version 5.8.0 - 2020-11-02 diff --git a/__init__.py b/__init__.py index d9f97d0bd0..f9949c693a 100644 --- a/__init__.py +++ b/__init__.py @@ -12,6 +12,7 @@ from . import configuration from . import party from . import ir from . import res +from . import stock_reporting_margin from .move import StockMixin @@ -55,6 +56,12 @@ def register(): configuration.ConfigurationLocation, ir.Cron, res.User, + stock_reporting_margin.Context, + stock_reporting_margin.Product, + stock_reporting_margin.ProductTimeseries, + stock_reporting_margin.Category, + stock_reporting_margin.CategoryTimeseries, + stock_reporting_margin.CategoryTree, module='stock', type_='model') Pool.register( shipment.Assign, diff --git a/message.xml b/message.xml index e50f9a5fed..96bb0ba5e6 100644 --- a/message.xml +++ b/message.xml @@ -92,5 +92,29 @@ because the type does not support moves and location has existing moves. The inventories "%(inventories)s" have dates in the future. + + Company + + + Number + + + Cost + + + Revenue + + + Profit + + + Margin + + + Margin Trend + + + Currency Digits + diff --git a/setup.py b/setup.py index e5b3e01a77..cf97dc27ca 100755 --- a/setup.py +++ b/setup.py @@ -137,6 +137,9 @@ setup(name=name, license='GPL-3', python_requires='>=3.6', install_requires=requires, + extras_require={ + 'sparklines': ['pygal'], + }, dependency_links=dependency_links, zip_safe=False, entry_points=""" diff --git a/stock_reporting_margin.py b/stock_reporting_margin.py new file mode 100644 index 0000000000..93592c1443 --- /dev/null +++ b/stock_reporting_margin.py @@ -0,0 +1,629 @@ +# 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 decimal import Decimal +from itertools import tee, zip_longest + +from sql import Null, Literal, With +from sql.aggregate import Sum, Min, Max +from sql.conditionals import Case +from sql.functions import CurrentTimestamp, DateTrunc, Power, Ceil, Log, Round + +try: + import pygal +except ImportError: + pygal = None +from dateutil.relativedelta import relativedelta + +from trytond.i18n import lazy_gettext +from trytond.model import ModelSQL, ModelView, fields +from trytond.pool import Pool +from trytond.pyson import Eval, If +from trytond.tools import grouped_slice, reduce_ids +from trytond.transaction import Transaction + + +def pairwise(iterable): + a, b = tee(iterable) + next(b) + return zip_longest(a, b) + + +class Abstract(ModelSQL, ModelView): + + company = fields.Many2One( + 'company.company', lazy_gettext('stock.msg_stock_reporting_company')) + cost = fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_cost'), + digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']) + revenue = fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_revenue'), + digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']) + profit = fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_profit'), + digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']) + margin = fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_margin'), + digits=(14, 4), + states={ + 'invisible': ~Eval('margin'), + }, + depends=['currency_digits']) + margin_trend = fields.Function(fields.Char( + lazy_gettext('stock.msg_stock_reporting_margin_trend')), + 'get_trend') + time_series = None + + currency = fields.Many2One('currency.currency', "Currency") + currency_digits = fields.Integer( + lazy_gettext('stock.msg_stock_reporting_currency_digits')) + + @classmethod + def table_query(cls): + from_item, tables, withs = cls._joins() + return from_item.select(*cls._columns(tables, withs), + where=cls._where(tables, withs), + group_by=cls._group_by(tables, withs), + with_=withs.values()) + + @classmethod + def _joins(cls): + pool = Pool() + Company = pool.get('company.company') + Currency = pool.get('currency.currency') + Move = pool.get('stock.move') + Location = pool.get('stock.location') + + tables = {} + tables['move'] = move = Move.__table__() + tables['move.company'] = company = Company.__table__() + tables['move.company.currency'] = currency = Currency.__table__() + tables['move.from_location'] = from_location = Location.__table__() + tables['move.to_location'] = to_location = Location.__table__() + withs = {} + withs['currency_rate'] = currency_rate = With( + query=Currency.currency_rate_sql()) + withs['currency_rate_company'] = currency_rate_company = With( + query=Currency.currency_rate_sql()) + + from_item = (move + .join(currency_rate, + condition=(move.currency == currency_rate.currency) + & (currency_rate.start_date <= move.effective_date) + & ((currency_rate.end_date == Null) + | (currency_rate.end_date >= move.effective_date)) + ) + .join(company, + condition=move.company == company.id) + .join(currency, + condition=company.currency == currency.id) + .join(currency_rate_company, + condition=(company.currency == currency_rate_company.currency) + & (currency_rate_company.start_date <= move.effective_date) + & ((currency_rate_company.end_date == Null) + | (currency_rate_company.end_date >= move.effective_date)) + ) + .join(from_location, + condition=(move.from_location == from_location.id)) + .join(to_location, + condition=(move.to_location == to_location.id))) + return from_item, tables, withs + + @classmethod + def _columns(cls, tables, withs): + move = tables['move'] + from_location = tables['move.from_location'] + to_location = tables['move.to_location'] + currency = tables['move.company.currency'] + + sign = Case( + (from_location.type.in_(cls._to_location_types()) + & to_location.type.in_(cls._from_location_types()), + -1), + else_=1) + cost = cls._column_cost(tables, withs, sign) + revenue = cls._column_revenue(tables, withs, sign) + profit = revenue - cost + margin = Case( + (revenue != 0, profit / revenue), + else_=Null) + return [ + cls._column_id(tables, withs).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'), + move.company.as_('company'), + cls.cost.sql_cast( + Round(cost, currency.digits)).as_('cost'), + cls.revenue.sql_cast( + Round(revenue, currency.digits)).as_('revenue'), + cls.profit.sql_cast( + Round(profit, currency.digits)).as_('profit'), + cls.margin.sql_cast( + Round(margin, cls.margin.digits[1])).as_('margin'), + currency.id.as_('currency'), + currency.digits.as_('currency_digits'), + ] + + @classmethod + def _column_id(cls, tables, withs): + move = tables['move'] + return Min(move.id) + + @classmethod + def _column_cost(cls, tables, withs, sign): + move = tables['move'] + return Sum( + sign * cls.cost.sql_cast(move.internal_quantity) * move.cost_price) + + @classmethod + def _column_revenue(cls, tables, withs, sign): + move = tables['move'] + currency = withs['currency_rate'] + currency_company = withs['currency_rate_company'] + return Sum( + sign * cls.revenue.sql_cast(move.quantity) * move.unit_price + * currency_company.rate / currency.rate) + + @classmethod + def _group_by(cls, tables, withs): + move = tables['move'] + currency = tables['move.company.currency'] + return [move.company, currency.id, currency.digits] + + @classmethod + def _where(cls, tables, withs): + context = Transaction().context + move = tables['move'] + from_location = tables['move.from_location'] + to_location = tables['move.to_location'] + + where = move.company == context.get('company') + where &= (( + from_location.type.in_(cls._from_location_types()) + & to_location.type.in_(cls._to_location_types())) + | ( + from_location.type.in_(cls._to_location_types()) + & to_location.type.in_(cls._from_location_types()))) + where &= move.state == 'done' + from_date = context.get('from_date') + if from_date: + where &= move.effective_date >= from_date + to_date = context.get('to_date') + if to_date: + where &= move.effective_date <= to_date + return where + + @classmethod + def _from_location_types(cls): + return ['storage', 'drop'] + + @classmethod + def _to_location_types(cls): + types = ['customer'] + if Transaction().context.get('include_lost'): + types += ['lost_found'] + return types + + @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) or 0 if ts else 0 + for ts in self.time_series_all]) + return chart.render_sparktext() + + @classmethod + def view_attributes(cls): + return super().view_attributes() + [ + ('/tree/field[@name="profit"]', 'visual', + If(Eval('profit', 0) < 0, 'danger', '')), + ('/tree/field[@name="margin"]', 'visual', + If(Eval('margin', 0) < 0, 'danger', '')), + ] + + +class AbstractTimeseries(Abstract): + + date = fields.Date("Date") + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order = [('date', 'ASC')] + + @classmethod + def _columns(cls, tables, withs): + return super()._columns(tables, withs) + [ + cls._column_date(tables, withs).as_('date')] + + @classmethod + def _column_date(cls, tables, withs): + context = Transaction().context + move = tables['move'] + date = DateTrunc(context.get('period'), move.effective_date) + date = cls.date.sql_cast(date) + return date + + @classmethod + def _group_by(cls, tables, withs): + return super()._group_by(tables, withs) + [ + cls._column_date(tables, withs)] + + +class Context(ModelView): + "Stock Reporting Margin Context" + __name__ = 'stock.reporting.margin.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) + include_lost = fields.Boolean( + "Include Lost", + help="If checked, the cost of product moved " + "to a lost and found location is included.") + + @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_include_lost(cls): + return Transaction().context.get('include_lost', False) + + +class ProductMixin: + __slots__ = () + + product = fields.Many2One('product.product', "Product") + internal_quantity = fields.Float("Internal Quantity") + quantity = fields.Function(fields.Float( + "Quantity", digits=(16, Eval('unit_digits', 2)), + depends=['unit_digits']), 'get_quantity') + unit = fields.Function(fields.Many2One('product.uom', "Unit"), 'get_unit') + unit_digits = fields.Function( + fields.Integer("Unit Digits"), 'get_unit_digits') + + @classmethod + def _columns(cls, tables, withs): + move = tables['move'] + from_location = tables['move.from_location'] + to_location = tables['move.to_location'] + sign = Case( + (from_location.type.in_(cls._to_location_types()) + & to_location.type.in_(cls._from_location_types()), + -1), + else_=1) + return super()._columns(tables, withs) + [ + move.product.as_('product'), + Sum(sign * move.internal_quantity).as_('internal_quantity'), + ] + + @classmethod + def _group_by(cls, tables, withs): + move = tables['move'] + return super()._group_by(tables, withs) + [move.product] + + def get_rec_name(self, name): + return self.product.rec_name + + def get_quantity(self, name): + return self.unit.round(self.internal_quantity) + + def get_unit(self, name): + return self.product.default_uom.id + + def get_unit_digits(self, name): + return self.product.default_uom.digits + + +class Product(ProductMixin, Abstract, ModelView): + "Stock Reporting Margin per Product" + __name__ = 'stock.reporting.margin.product' + + time_series = fields.One2Many( + 'stock.reporting.margin.product.time_series', 'product', "Time Series") + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order.insert(0, ('product', 'ASC')) + + @classmethod + def _column_id(cls, tables, withs): + move = tables['move'] + return move.product + + +class ProductTimeseries(ProductMixin, AbstractTimeseries, ModelView): + "Stock Reporting Margin per Product" + __name__ = 'stock.reporting.margin.product.time_series' + + +class CategoryMixin: + __slots__ = () + + 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, withs = super()._joins() + if 'move.product' not in tables: + product = Product.__table__() + tables['move.product'] = product + move = tables['move'] + from_item = (from_item + .join(product, condition=move.product == product.id)) + if 'move.product.template_category' not in tables: + template_category = TemplateCategory.__table__() + tables['move.product.template_category'] = template_category + product = tables['move.product'] + from_item = (from_item + .join(template_category, + condition=product.template == template_category.template)) + return from_item, tables, withs + + @classmethod + def _columns(cls, tables, withs): + template_category = tables['move.product.template_category'] + return super()._columns(tables, withs) + [ + template_category.category.as_('category')] + + @classmethod + def _column_id(cls, tables, withs): + pool = Pool() + Category = pool.get('product.category') + category = Category.__table__() + move = tables['move'] + template_category = tables['move.product.template_category'] + # Get a stable number of category over time + # by using number one order bigger. + nb_category = category.select( + Power(10, (Ceil(Log(Max(category.id))) + Literal(1)))) + return Min(move.id * nb_category + template_category.id) + + @classmethod + def _group_by(cls, tables, withs): + template_category = tables['move.product.template_category'] + return super()._group_by(tables, withs) + [template_category.category] + + @classmethod + def _where(cls, tables, withs): + template_category = tables['move.product.template_category'] + where = super()._where(tables, withs) + 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): + "Stock Reporting Margin per Category" + __name__ = 'stock.reporting.margin.category' + + time_series = fields.One2Many( + 'stock.reporting.margin.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): + template_category = tables['move.product.template_category'] + return template_category.category + + +class CategoryTimeseries(CategoryMixin, AbstractTimeseries, ModelView): + "Stock Reporting Margin per Category" + __name__ = 'stock.reporting.margin.category.time_series' + + +class CategoryTree(ModelSQL, ModelView): + "Stock Reporting Margin per Category" + __name__ = 'stock.reporting.margin.category.tree' + + name = fields.Function(fields.Char("Name"), 'get_name') + parent = fields.Many2One('stock.reporting.margin.category.tree', "Parent") + children = fields.One2Many( + 'stock.reporting.margin.category.tree', 'parent', "Children") + cost = fields.Function(fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_cost'), + digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']), 'get_total') + revenue = fields.Function(fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_revenue'), + digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']), 'get_total') + profit = fields.Function(fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_profit'), + digits=(16, Eval('currency_digits', 2)), + depends=['currency_digits']), 'get_total') + margin = fields.Function(fields.Numeric( + lazy_gettext('stock.msg_stock_reporting_margin'), + digits=(14, 4), + depends=['currency_digits']), + 'get_margin') + + currency_digits = fields.Function(fields.Integer( + lazy_gettext('stock.msg_stock_reporting_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('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} + + @classmethod + def order_name(cls, tables): + pool = Pool() + Category = pool.get('product.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() + ReportingCategory = pool.get('stock.reporting.margin.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.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_margin(self, name): + digits = self.__class__.margin.digits + if self.profit is not None and self.revenue: + return (self.profit / self.revenue).quantize( + Decimal(1) / 10 ** digits[1]) + + 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 + + @classmethod + def view_attributes(cls): + return super().view_attributes() + [ + ('/tree/field[@name="profit"]', 'visual', + If(Eval('profit', 0) < 0, 'danger', '')), + ('/tree/field[@name="margin"]', 'visual', + If(Eval('margin', 0) < 0, 'danger', '')), + ] diff --git a/stock_reporting_margin.xml b/stock_reporting_margin.xml new file mode 100644 index 0000000000..8fe86ef7f2 --- /dev/null +++ b/stock_reporting_margin.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + + stock.reporting.margin.context + form + reporting_margin_context_form + + + + + + stock.reporting.margin.product + tree + reporting_margin_product_list + + + + stock.reporting.margin.product + graph + reporting_margin_product_graph_margin + + + + stock.reporting.margin.product + graph + reporting_margin_product_graph_profit + + + + stock.reporting.margin.product + graph + reporting_margin_product_graph_amount + + + + Margins per Product + stock.reporting.margin.product + stock.reporting.margin.context + + + + + + + + + + + + + + + + + + + + + + + tree_open + + + + + + + + + + + + + + + + + + + + + + stock.reporting.margin.product.time_series + tree + reporting_margin_product_time_series_list + + + + stock.reporting.margin.product.time_series + graph + reporting_margin_product_time_series_graph_margin + + + + stock.reporting.margin.product.time_series + graph + reporting_margin_product_time_series_graph_profit + + + + stock.reporting.margin.product.time_series + graph + reporting_margin_product_time_series_graph_amount + + + + Margins per Product + stock.reporting.margin.product.time_series + stock.reporting.margin.context + + + + + + + + + + + + + + + + + + + + + + + + + tree_open + stock.reporting.margin.product,-1 + + + + + + + + + + + + + + + + + + + + + + + stock.reporting.margin.category.tree + tree + children + reporting_margin_category_tree + + + + Margins per Category + stock.reporting.margin.category.tree + stock.reporting.margin.context + + + + + + + + + tree_open + + + + + + + + + + + + + + + + + + + + + + stock.reporting.margin.category + tree + reporting_margin_category_list + + + + stock.reporting.margin.category + graph + reporting_margin_category_graph_margin + + + + stock.reporting.margin.category + graph + reporting_margin_category_graph_profit + + + + stock.reporting.margin.category + graph + reporting_margin_category_graph_amount + + + + Margins per Category + stock.reporting.margin.category + stock.reporting.margin.context + + + + + + + + + + + + + + + + + + + + + + + + tree_open + stock.reporting.margin.category.tree,-1 + + + + + + + + + + + + + + + + + + + + + stock.reporting.margin.category.time_series + tree + reporting_margin_category_time_series_list + + + + stock.reporting.margin.category.time_series + graph + reporting_margin_category_time_series_graph_margin + + + + stock.reporting.margin.category.time_series + graph + reporting_margin_category_time_series_graph_profit + + + + stock.reporting.margin.category.time_series + graph + reporting_margin_category_time_series_graph_amount + + + + Margins per Category + stock.reporting.margin.category.time_series + stock.reporting.margin.context + + + + + + + + + + + + + + + + + + + + + + + + + tree_open + stock.reporting.margin.category,-1 + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scenario_stock_reporting_margin.rst b/tests/scenario_stock_reporting_margin.rst new file mode 100644 index 0000000000..3e2cf19efe --- /dev/null +++ b/tests/scenario_stock_reporting_margin.rst @@ -0,0 +1,215 @@ +=============================== +Stock Reporting Margin Scenario +=============================== + +Imports:: + + >>> import datetime as dt + >>> 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) + >>> today = dt.date.today() + >>> yesterday = today - dt.timedelta(days=1) + +Activate modules:: + + >>> config = activate_modules('stock') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create product:: + + >>> ProductUom = Model.get('product.uom') + >>> ProductTemplate = Model.get('product.template') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + >>> template = ProductTemplate() + >>> template.name = 'Product' + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.list_price = Decimal('40') + >>> template.save() + >>> product, = template.products + >>> product.cost_price = Decimal('20') + >>> product.save() + >>> template2, = template.duplicate() + >>> product2, = template2.products + + >>> Category = Model.get('product.category') + >>> category_root = Category(name="Root") + >>> category_root.save() + >>> category1 = Category(name="Child1", parent=category_root) + >>> category1.save() + >>> category2 = Category(name="Child2", parent=category_root) + >>> category2.save() + + >>> template.categories.append(Category(category1.id)) + >>> template.save() + >>> template2.categories.append(Category(category2.id)) + >>> template2.save() + + +Get stock locations:: + + >>> Location = Model.get('stock.location') + >>> supplier_loc, = Location.find([('code', '=', 'SUP')]) + >>> customer_loc, = Location.find([('code', '=', 'CUS')]) + >>> storage_loc, = Location.find([('code', '=', 'STO')]) + >>> lost_loc, = Location.find([('type', '=', 'lost_found')]) + +Create some moves:: + + >>> Move = Model.get('stock.move') + >>> move = Move() + >>> move.product = product + >>> move.from_location = supplier_loc + >>> move.to_location = storage_loc + >>> move.quantity = 8 + >>> move.unit_price = Decimal('20') + >>> move.effective_date = yesterday + >>> move.click('do') + + >>> move = Move() + >>> move.product = product + >>> move.from_location = storage_loc + >>> move.to_location = customer_loc + >>> move.quantity = 2 + >>> move.unit_price = Decimal('40') + >>> move.effective_date = yesterday + >>> move.click('do') + + >>> move = Move() + >>> move.product = product + >>> move.from_location = storage_loc + >>> move.to_location = customer_loc + >>> move.quantity = 4 + >>> move.unit_price = Decimal('30') + >>> move.effective_date = today + >>> move.click('do') + + >>> move = Move() + >>> move.product = product + >>> move.from_location = customer_loc + >>> move.to_location = storage_loc + >>> move.quantity = 1 + >>> move.unit_price = Decimal('30') + >>> move.effective_date = today + >>> move.click('do') + + >>> move = Move() + >>> move.product = product2 + >>> move.from_location = storage_loc + >>> move.to_location = customer_loc + >>> move.quantity = 2 + >>> move.unit_price = Decimal('50') + >>> move.effective_date = today + >>> move.click('do') + + >>> move = Move() + >>> move.product = product + >>> move.from_location = storage_loc + >>> move.to_location = lost_loc + >>> move.quantity = 1 + >>> move.effective_date = today + >>> move.click('do') + +Check reporting margin per product:: + + >>> MarginProduct = Model.get('stock.reporting.margin.product') + >>> MarginProductTimeseries = Model.get( + ... 'stock.reporting.margin.product.time_series') + >>> context = { + ... 'from_date': yesterday, + ... 'to_date': today, + ... 'period': 'day', + ... } + >>> with config.set_context(context=context): + ... reports = MarginProduct.find([]) + ... time_series = MarginProductTimeseries.find([]) + >>> len(reports) + 2 + >>> report, = [r for r in reports if r.product == product] + >>> (report.quantity, report.cost, report.revenue, + ... report.profit, report.margin) == ( + ... 5, Decimal('100.00'), Decimal('170.00'), + ... Decimal('70.00'), Decimal('0.4118')) + True + >>> len(time_series) + 3 + >>> with config.set_context(context=context): + ... sorted(( + ... r.product.id, str(r.date), r.quantity, r.cost, r.revenue, + ... r.profit, r.margin) + ... for r in time_series) == sorted([ + ... (product.id, str(yesterday), 2, Decimal('40.00'), Decimal('80.00'), + ... Decimal('40.00'), Decimal('0.5000')), + ... (product.id, str(today), 3, Decimal('60.00'), Decimal('90.00'), + ... Decimal('30.00'), Decimal('0.3333')), + ... (product2.id, str(today), 2, Decimal('40.00'), Decimal('100.00'), + ... Decimal('60.00'), Decimal('0.6000'))]) + True + +Check reporting margin per categories:: + + >>> MarginCategory = Model.get('stock.reporting.margin.category') + >>> MarginCategoryTimeseries = Model.get( + ... 'stock.reporting.margin.category.time_series') + >>> MarginCategoryTree = Model.get( + ... 'stock.reporting.margin.category.tree') + >>> with config.set_context(context=context): + ... reports = MarginCategory.find([]) + ... time_series = MarginCategoryTimeseries.find([]) + ... tree = MarginCategoryTree.find([]) + >>> len(reports) + 2 + >>> with config.set_context(context=context): + ... sorted((r.category.id, r.cost, r.revenue, r.profit, r.margin) + ... for r in reports) == sorted([ + ... (category1.id, Decimal('100.00'), Decimal('170.00'), + ... Decimal('70.00'), Decimal('0.4118')), + ... (category2.id, Decimal('40.00'), Decimal('100.00'), + ... Decimal('60.00'), Decimal('0.6000'))]) + True + >>> len(time_series) + 3 + >>> with config.set_context(context=context): + ... sorted((r.category.id, str(r.date), r.cost, r.revenue, r.profit, r.margin) + ... for r in time_series) == sorted([ + ... (category1.id, str(yesterday), Decimal('40.00'), Decimal('80.00'), + ... Decimal('40.00'), Decimal('0.5000')), + ... (category1.id, str(today), Decimal('60.00'), Decimal('90.00'), + ... Decimal('30.00'), Decimal('0.3333')), + ... (category2.id, str(today), Decimal('40.00'), Decimal('100.00'), + ... Decimal('60.00'), Decimal('0.6000'))]) + True + >>> len(tree) + 3 + >>> with config.set_context(context=context): + ... sorted((r.name, r.cost, r.revenue, r.profit, r.margin) + ... for r in tree) == sorted([ + ... ("Root", Decimal('140.00'), Decimal('270.00'), + ... Decimal('130.00'), Decimal('0.4815')), + ... ("Child1", Decimal('100.00'), Decimal('170.00'), + ... Decimal('70.00'), Decimal('0.4118')), + ... ('Child2', Decimal('40.00'), Decimal('100.00'), + ... Decimal('60.00'), Decimal('0.6000'))]) + True + +Check reporting margin including lost:: + + >>> context['include_lost'] = True + + >>> with config.set_context(context=context): + ... reports = MarginProduct.find([]) + >>> len(reports) + 2 + >>> report, = [r for r in reports if r.product == product] + >>> (report.quantity, report.cost, report.revenue, + ... report.profit, report.margin) == ( + ... 6, Decimal('120.00'), Decimal('170.00'), + ... Decimal('50.00'), Decimal('0.2941')) + True diff --git a/tests/test_stock.py b/tests/test_stock.py index c9629a6cdf..f9eb05edef 100644 --- a/tests/test_stock.py +++ b/tests/test_stock.py @@ -1563,4 +1563,9 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', checker=doctest_checker, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite( + 'scenario_stock_reporting_margin.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite diff --git a/tryton.cfg b/tryton.cfg index 02e1f3e0b8..f6e846f15e 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -19,3 +19,4 @@ xml: period.xml message.xml res.xml + stock_reporting_margin.xml diff --git a/view/reporting_margin_category_graph_amount.xml b/view/reporting_margin_category_graph_amount.xml new file mode 100644 index 0000000000..f42f415f10 --- /dev/null +++ b/view/reporting_margin_category_graph_amount.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/view/reporting_margin_category_graph_margin.xml b/view/reporting_margin_category_graph_margin.xml new file mode 100644 index 0000000000..9c38e9f9fe --- /dev/null +++ b/view/reporting_margin_category_graph_margin.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/view/reporting_margin_category_graph_profit.xml b/view/reporting_margin_category_graph_profit.xml new file mode 100644 index 0000000000..386263357d --- /dev/null +++ b/view/reporting_margin_category_graph_profit.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/view/reporting_margin_category_list.xml b/view/reporting_margin_category_list.xml new file mode 100644 index 0000000000..2a1582c70a --- /dev/null +++ b/view/reporting_margin_category_list.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/view/reporting_margin_category_time_series_graph_amount.xml b/view/reporting_margin_category_time_series_graph_amount.xml new file mode 100644 index 0000000000..2f8746e2ea --- /dev/null +++ b/view/reporting_margin_category_time_series_graph_amount.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/view/reporting_margin_category_time_series_graph_margin.xml b/view/reporting_margin_category_time_series_graph_margin.xml new file mode 100644 index 0000000000..eb7c79959f --- /dev/null +++ b/view/reporting_margin_category_time_series_graph_margin.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/view/reporting_margin_category_time_series_graph_profit.xml b/view/reporting_margin_category_time_series_graph_profit.xml new file mode 100644 index 0000000000..b3dfec06fd --- /dev/null +++ b/view/reporting_margin_category_time_series_graph_profit.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/view/reporting_margin_category_time_series_list.xml b/view/reporting_margin_category_time_series_list.xml new file mode 100644 index 0000000000..8ef602efbb --- /dev/null +++ b/view/reporting_margin_category_time_series_list.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/view/reporting_margin_category_tree.xml b/view/reporting_margin_category_tree.xml new file mode 100644 index 0000000000..121a0073ed --- /dev/null +++ b/view/reporting_margin_category_tree.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/view/reporting_margin_context_form.xml b/view/reporting_margin_context_form.xml new file mode 100644 index 0000000000..8b8d055da4 --- /dev/null +++ b/view/reporting_margin_context_form.xml @@ -0,0 +1,20 @@ + + +
+ + +