changeset fcb961429f35 in modules/stock:default
details: https://hg.tryton.org/modules/stock?cmd=changeset;node=fcb961429f35
description:
        Add margin reporting

        issue9665
        review292371002
diffstat:

 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 +
 view/reporting_margin_category_graph_amount.xml             |   12 +
 view/reporting_margin_category_graph_margin.xml             |   11 +
 view/reporting_margin_category_graph_profit.xml             |   11 +
 view/reporting_margin_category_list.xml                     |   13 +
 view/reporting_margin_category_time_series_graph_amount.xml |   12 +
 view/reporting_margin_category_time_series_graph_margin.xml |   11 +
 view/reporting_margin_category_time_series_graph_profit.xml |   11 +
 view/reporting_margin_category_time_series_list.xml         |   12 +
 view/reporting_margin_category_tree.xml                     |   12 +
 view/reporting_margin_context_form.xml                      |   20 +
 view/reporting_margin_product_graph_amount.xml              |   12 +
 view/reporting_margin_product_graph_margin.xml              |   11 +
 view/reporting_margin_product_graph_profit.xml              |   11 +
 view/reporting_margin_product_list.xml                      |   14 +
 view/reporting_margin_product_time_series_graph_amount.xml  |   12 +
 view/reporting_margin_product_time_series_graph_margin.xml  |   11 +
 view/reporting_margin_product_time_series_graph_profit.xml  |   11 +
 view/reporting_margin_product_time_series_list.xml          |   13 +
 27 files changed, 1465 insertions(+), 0 deletions(-)

diffs (1607 lines):

diff -r c9582871ec46 -r fcb961429f35 CHANGELOG
--- a/CHANGELOG Sun Jan 17 00:43:31 2021 +0100
+++ b/CHANGELOG Sun Jan 17 00:57:09 2021 +0100
@@ -1,3 +1,4 @@
+* Add margin reporting
 * Add products_by_locations Model
 
 Version 5.8.0 - 2020-11-02
diff -r c9582871ec46 -r fcb961429f35 __init__.py
--- a/__init__.py       Sun Jan 17 00:43:31 2021 +0100
+++ b/__init__.py       Sun Jan 17 00:57:09 2021 +0100
@@ -12,6 +12,7 @@
 from . import party
 from . import ir
 from . import res
+from . import stock_reporting_margin
 
 from .move import StockMixin
 
@@ -55,6 +56,12 @@
         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 -r c9582871ec46 -r fcb961429f35 message.xml
--- a/message.xml       Sun Jan 17 00:43:31 2021 +0100
+++ b/message.xml       Sun Jan 17 00:57:09 2021 +0100
@@ -92,5 +92,29 @@
         <record model="ir.message" id="msg_inventory_date_in_the_future">
             <field name="text">The inventories "%(inventories)s" have dates in 
the future.</field>
         </record>
+        <record model="ir.message" id="msg_stock_reporting_company">
+            <field name="text">Company</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_number">
+            <field name="text">Number</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_cost">
+            <field name="text">Cost</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_revenue">
+            <field name="text">Revenue</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_profit">
+            <field name="text">Profit</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_margin">
+            <field name="text">Margin</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_margin_trend">
+            <field name="text">Margin Trend</field>
+        </record>
+        <record model="ir.message" id="msg_stock_reporting_currency_digits">
+            <field name="text">Currency Digits</field>
+        </record>
     </data>
 </tryton>
diff -r c9582871ec46 -r fcb961429f35 setup.py
--- a/setup.py  Sun Jan 17 00:43:31 2021 +0100
+++ b/setup.py  Sun Jan 17 00:57:09 2021 +0100
@@ -137,6 +137,9 @@
     license='GPL-3',
     python_requires='>=3.6',
     install_requires=requires,
+    extras_require={
+        'sparklines': ['pygal'],
+        },
     dependency_links=dependency_links,
     zip_safe=False,
     entry_points="""
diff -r c9582871ec46 -r fcb961429f35 stock_reporting_margin.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/stock_reporting_margin.py Sun Jan 17 00:57:09 2021 +0100
@@ -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 -r c9582871ec46 -r fcb961429f35 stock_reporting_margin.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/stock_reporting_margin.xml        Sun Jan 17 00:57:09 2021 +0100
@@ -0,0 +1,360 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tryton>
+    <data>
+        <menuitem parent="product.menu_reporting" name="Margins" 
id="menu_reporting_margin" icon="tryton-graph"/>
+        <record model="ir.ui.menu-res.group" 
id="menu_reporting_margin_group_product_admin">
+            <field name="menu" ref="menu_reporting_margin"/>
+            <field name="group" ref="product.group_product_admin"/>
+        </record>
+
+        <record model="ir.ui.view" id="reporting_margin_context_view_form">
+            <field name="model">stock.reporting.margin.context</field>
+            <field name="type">form</field>
+            <field name="name">reporting_margin_context_form</field>
+        </record>
+
+        <!-- Product -->
+
+        <record model="ir.ui.view" id="reporting_margin_product_view_list">
+            <field name="model">stock.reporting.margin.product</field>
+            <field name="type">tree</field>
+            <field name="name">reporting_margin_product_list</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_view_graph_margin">
+            <field name="model">stock.reporting.margin.product</field>
+            <field name="type">graph</field>
+            <field name="name">reporting_margin_product_graph_margin</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_view_graph_profit">
+            <field name="model">stock.reporting.margin.product</field>
+            <field name="type">graph</field>
+            <field name="name">reporting_margin_product_graph_profit</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_view_graph_amount">
+            <field name="model">stock.reporting.margin.product</field>
+            <field name="type">graph</field>
+            <field name="name">reporting_margin_product_graph_amount</field>
+        </record>
+
+        <record model="ir.action.act_window" id="act_reporting_margin_product">
+            <field name="name">Margins per Product</field>
+            <field name="res_model">stock.reporting.margin.product</field>
+            <field name="context_model">stock.reporting.margin.context</field>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="reporting_margin_product_view_list"/>
+            <field name="act_window" ref="act_reporting_margin_product"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" 
ref="reporting_margin_product_view_graph_margin"/>
+            <field name="act_window" ref="act_reporting_margin_product"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_view3">
+            <field name="sequence" eval="30"/>
+            <field name="view" 
ref="reporting_margin_product_view_graph_profit"/>
+            <field name="act_window" ref="act_reporting_margin_product"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_view4">
+            <field name="sequence" eval="40"/>
+            <field name="view" 
ref="reporting_margin_product_view_graph_amount"/>
+            <field name="act_window" ref="act_reporting_margin_product"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_reporting_margin_product_keyword1">
+            <field name="keyword">tree_open</field>
+            <field name="model" eval="'ir.ui.menu,%s' % 
ref('menu_reporting_margin')"/>
+            <field name="action" ref="act_reporting_margin_product"/>
+        </record>
+
+        <record model="ir.model.access" id="access_reporting_margin_product">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.product')]"/>
+            <field name="perm_read" eval="False"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" 
id="access_reporting_margin_product_product_admin">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.product')]"/>
+            <field name="group" ref="product.group_product_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_time_series_view_list">
+            <field 
name="model">stock.reporting.margin.product.time_series</field>
+            <field name="type">tree</field>
+            <field 
name="name">reporting_margin_product_time_series_list</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_time_series_view_graph_margin">
+            <field 
name="model">stock.reporting.margin.product.time_series</field>
+            <field name="type">graph</field>
+            <field 
name="name">reporting_margin_product_time_series_graph_margin</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_time_series_view_graph_profit">
+            <field 
name="model">stock.reporting.margin.product.time_series</field>
+            <field name="type">graph</field>
+            <field 
name="name">reporting_margin_product_time_series_graph_profit</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_product_time_series_view_graph_amount">
+            <field 
name="model">stock.reporting.margin.product.time_series</field>
+            <field name="type">graph</field>
+            <field 
name="name">reporting_margin_product_time_series_graph_amount</field>
+        </record>
+
+        <record model="ir.action.act_window" 
id="act_reporting_margin_product_time_series">
+            <field name="name">Margins per Product</field>
+            <field 
name="res_model">stock.reporting.margin.product.time_series</field>
+            <field name="context_model">stock.reporting.margin.context</field>
+            <field
+                name="domain"
+                eval="[('product', '=', Eval('active_id', -1))]"
+                pyson="1"/>
+            <field name="order" eval="[('date', 'DESC')]" pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_time_series_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" 
ref="reporting_margin_product_time_series_view_list"/>
+            <field name="act_window" 
ref="act_reporting_margin_product_time_series"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_time_series_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" 
ref="reporting_margin_product_time_series_view_graph_margin"/>
+            <field name="act_window" 
ref="act_reporting_margin_product_time_series"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_time_series_view3">
+            <field name="sequence" eval="30"/>
+            <field name="view" 
ref="reporting_margin_product_time_series_view_graph_profit"/>
+            <field name="act_window" 
ref="act_reporting_margin_product_time_series"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_product_time_series_view4">
+            <field name="sequence" eval="40"/>
+            <field name="view" 
ref="reporting_margin_product_time_series_view_graph_amount"/>
+            <field name="act_window" 
ref="act_reporting_margin_product_time_series"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_reporting_margin_product_time_series_keyword1">
+            <field name="keyword">tree_open</field>
+            <field name="model">stock.reporting.margin.product,-1</field>
+            <field name="action" 
ref="act_reporting_margin_product_time_series"/>
+        </record>
+
+        <record model="ir.model.access" 
id="access_reporting_margin_product_time_series">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.product.time_series')]"/>
+            <field name="perm_read" eval="False"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" 
id="access_reporting_margin_product_time_series_product_admin">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.product.time_series')]"/>
+            <field name="group" ref="product.group_product_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+
+        <!-- Category -->
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_tree_view_tree">
+            <field name="model">stock.reporting.margin.category.tree</field>
+            <field name="type">tree</field>
+            <field name="field_childs">children</field>
+            <field name="name">reporting_margin_category_tree</field>
+        </record>
+
+        <record model="ir.action.act_window" 
id="act_reporting_margin_category_tree">
+            <field name="name">Margins per Category</field>
+            <field 
name="res_model">stock.reporting.margin.category.tree</field>
+            <field name="context_model">stock.reporting.margin.context</field>
+            <field name="domain" eval="[('parent', '=', None)]" pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_tree_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="reporting_margin_category_tree_view_tree"/>
+            <field name="act_window" ref="act_reporting_margin_category_tree"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_reporting_margin_category_tree_keyword1">
+            <field name="keyword">tree_open</field>
+            <field name="model" eval="'ir.ui.menu,%s' % 
ref('menu_reporting_margin')"/>
+            <field name="action" ref="act_reporting_margin_category_tree"/>
+        </record>
+
+        <record model="ir.model.access" 
id="access_reporting_margin_category_tree">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.category.tree')]"/>
+            <field name="perm_read" eval="False"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" 
id="access_reporting_margin_category_tree_product_admin">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.category.tree')]"/>
+            <field name="group" ref="product.group_product_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+
+        <record model="ir.ui.view" id="reporting_margin_category_view_list">
+            <field name="model">stock.reporting.margin.category</field>
+            <field name="type">tree</field>
+            <field name="name">reporting_margin_category_list</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_view_graph_margin">
+            <field name="model">stock.reporting.margin.category</field>
+            <field name="type">graph</field>
+            <field name="name">reporting_margin_category_graph_margin</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_view_graph_profit">
+            <field name="model">stock.reporting.margin.category</field>
+            <field name="type">graph</field>
+            <field name="name">reporting_margin_category_graph_profit</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_view_graph_amount">
+            <field name="model">stock.reporting.margin.category</field>
+            <field name="type">graph</field>
+            <field name="name">reporting_margin_category_graph_amount</field>
+        </record>
+
+        <record model="ir.action.act_window" 
id="act_reporting_margin_category">
+            <field name="name">Margins per Category</field>
+            <field name="res_model">stock.reporting.margin.category</field>
+            <field name="context_model">stock.reporting.margin.context</field>
+            <field
+                name="domain"
+                eval="[('category', 'child_of', Eval('active_ids', []), 
'parent')]"
+                pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="reporting_margin_category_view_list"/>
+            <field name="act_window" ref="act_reporting_margin_category"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" 
ref="reporting_margin_category_view_graph_margin"/>
+            <field name="act_window" ref="act_reporting_margin_category"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_view3">
+            <field name="sequence" eval="30"/>
+            <field name="view" 
ref="reporting_margin_category_view_graph_profit"/>
+            <field name="act_window" ref="act_reporting_margin_category"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_view4">
+            <field name="sequence" eval="40"/>
+            <field name="view" 
ref="reporting_margin_category_view_graph_amount"/>
+            <field name="act_window" ref="act_reporting_margin_category"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_reporting_margin_category_keyword1">
+            <field name="keyword">tree_open</field>
+            <field name="model">stock.reporting.margin.category.tree,-1</field>
+            <field name="action" ref="act_reporting_margin_category"/>
+        </record>
+
+        <record model="ir.model.access" id="access_reporting_margin_category">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.category')]"/>
+            <field name="perm_read" eval="False"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" 
id="access_reporting_margin_category_product_admin">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.category')]"/>
+            <field name="group" ref="product.group_product_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_time_series_view_list">
+            <field 
name="model">stock.reporting.margin.category.time_series</field>
+            <field name="type">tree</field>
+            <field 
name="name">reporting_margin_category_time_series_list</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_time_series_view_graph_margin">
+            <field 
name="model">stock.reporting.margin.category.time_series</field>
+            <field name="type">graph</field>
+            <field 
name="name">reporting_margin_category_time_series_graph_margin</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_time_series_view_graph_profit">
+            <field 
name="model">stock.reporting.margin.category.time_series</field>
+            <field name="type">graph</field>
+            <field 
name="name">reporting_margin_category_time_series_graph_profit</field>
+        </record>
+
+        <record model="ir.ui.view" 
id="reporting_margin_category_time_series_view_graph_amount">
+            <field 
name="model">stock.reporting.margin.category.time_series</field>
+            <field name="type">graph</field>
+            <field 
name="name">reporting_margin_category_time_series_graph_amount</field>
+        </record>
+
+        <record model="ir.action.act_window" 
id="act_reporting_margin_category_time_series">
+            <field name="name">Margins per Category</field>
+            <field 
name="res_model">stock.reporting.margin.category.time_series</field>
+            <field name="context_model">stock.reporting.margin.context</field>
+            <field
+                name="domain"
+                eval="[('category', '=', Eval('active_id', -1))]"
+                pyson="1"/>
+            <field name="order" eval="[('date', 'DESC')]" pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_time_series_view1">
+            <field name="sequence" eval="10"/>
+            <field name="view" 
ref="reporting_margin_category_time_series_view_list"/>
+            <field name="act_window" 
ref="act_reporting_margin_category_time_series"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_time_series_view2">
+            <field name="sequence" eval="20"/>
+            <field name="view" 
ref="reporting_margin_category_time_series_view_graph_margin"/>
+            <field name="act_window" 
ref="act_reporting_margin_category_time_series"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_time_series_view3">
+            <field name="sequence" eval="30"/>
+            <field name="view" 
ref="reporting_margin_category_time_series_view_graph_profit"/>
+            <field name="act_window" 
ref="act_reporting_margin_category_time_series"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_reporting_margin_category_time_series_view4">
+            <field name="sequence" eval="40"/>
+            <field name="view" 
ref="reporting_margin_category_time_series_view_graph_amount"/>
+            <field name="act_window" 
ref="act_reporting_margin_category_time_series"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_reporting_margin_category_time_series_keyword1">
+            <field name="keyword">tree_open</field>
+            <field name="model">stock.reporting.margin.category,-1</field>
+            <field name="action" 
ref="act_reporting_margin_category_time_series"/>
+        </record>
+
+        <record model="ir.model.access" 
id="access_reporting_margin_category_time_series">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.category.time_series')]"/>
+            <field name="perm_read" eval="False"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" 
id="access_reporting_margin_category_time_series_product_admin">
+            <field name="model" search="[('model', '=', 
'stock.reporting.margin.category.time_series')]"/>
+            <field name="group" ref="product.group_product_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+    </data>
+</tryton>
diff -r c9582871ec46 -r fcb961429f35 tests/scenario_stock_reporting_margin.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/scenario_stock_reporting_margin.rst Sun Jan 17 00:57:09 2021 +0100
@@ -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 -r c9582871ec46 -r fcb961429f35 tests/test_stock.py
--- a/tests/test_stock.py       Sun Jan 17 00:43:31 2021 +0100
+++ b/tests/test_stock.py       Sun Jan 17 00:57:09 2021 +0100
@@ -1563,4 +1563,9 @@
             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 -r c9582871ec46 -r fcb961429f35 tryton.cfg
--- a/tryton.cfg        Sun Jan 17 00:43:31 2021 +0100
+++ b/tryton.cfg        Sun Jan 17 00:57:09 2021 +0100
@@ -19,3 +19,4 @@
     period.xml
     message.xml
     res.xml
+    stock_reporting_margin.xml
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_graph_amount.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_graph_amount.xml   Sun Jan 17 00:57:09 
2021 +0100
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="category"/>
+    </x>
+    <y>
+        <field name="cost"/>
+        <field name="revenue"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_graph_margin.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_graph_margin.xml   Sun Jan 17 00:57:09 
2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph type="line">
+    <x>
+        <field name="category"/>
+    </x>
+    <y>
+        <field name="margin" empty="0"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_graph_profit.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_graph_profit.xml   Sun Jan 17 00:57:09 
2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="category"/>
+    </x>
+    <y>
+        <field name="profit"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 view/reporting_margin_category_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_list.xml   Sun Jan 17 00:57:09 2021 +0100
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree keyword_open="1">
+    <field name="category" expand="1"/>
+    <field name="cost" symbol="currency"/>
+    <field name="revenue" symbol="currency"/>
+    <field name="profit" symbol="currency"/>
+    <field name="margin" factor="100">
+        <suffix string="%" name="margin"/>
+    </field>
+    <field name="margin_trend" expand="1"/>
+</tree>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_time_series_graph_amount.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_time_series_graph_amount.xml       Sun Jan 
17 00:57:09 2021 +0100
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="date"/>
+    </x>
+    <y>
+        <field name="cost"/>
+        <field name="revenue"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_time_series_graph_margin.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_time_series_graph_margin.xml       Sun Jan 
17 00:57:09 2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph type="line">
+    <x>
+        <field name="date"/>
+    </x>
+    <y>
+        <field name="margin" empty="0"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_time_series_graph_profit.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_time_series_graph_profit.xml       Sun Jan 
17 00:57:09 2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="date"/>
+    </x>
+    <y>
+        <field name="profit"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_category_time_series_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_time_series_list.xml       Sun Jan 17 
00:57:09 2021 +0100
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree>
+    <field name="date"/>
+    <field name="cost" symbol="currency"/>
+    <field name="revenue" symbol="currency"/>
+    <field name="profit" symbol="currency"/>
+    <field name="margin" factor="100">
+        <suffix string="%" name="margin"/>
+    </field>
+</tree>
diff -r c9582871ec46 -r fcb961429f35 view/reporting_margin_category_tree.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_category_tree.xml   Sun Jan 17 00:57:09 2021 +0100
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree keyword_open="1">
+    <field name="name" expand="1"/>
+    <field name="cost"/>
+    <field name="revenue"/>
+    <field name="profit"/>
+    <field name="margin" factor="100">
+        <suffix string="%" name="margin"/>
+    </field>
+</tree>
diff -r c9582871ec46 -r fcb961429f35 view/reporting_margin_context_form.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_context_form.xml    Sun Jan 17 00:57:09 2021 +0100
@@ -0,0 +1,20 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<form>
+    <group id="dates" colspan="2" col="4">
+        <label name="from_date"/>
+        <field name="from_date"/>
+        <label name="to_date"/>
+        <field name="to_date"/>
+    </group>
+    <label name="period"/>
+    <field name="period"/>
+
+    <group col="-1" colspan="2" id="checkboxes">
+        <label name="include_lost"/>
+        <field name="include_lost" xexpand="0" width="25"/>
+    </group>
+    <label name="company"/>
+    <field name="company"/>
+</form>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_graph_amount.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_graph_amount.xml    Sun Jan 17 00:57:09 
2021 +0100
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="product"/>
+    </x>
+    <y>
+        <field name="cost"/>
+        <field name="revenue"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_graph_margin.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_graph_margin.xml    Sun Jan 17 00:57:09 
2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph type="line">
+    <x>
+        <field name="product"/>
+    </x>
+    <y>
+        <field name="margin" empty="0"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_graph_profit.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_graph_profit.xml    Sun Jan 17 00:57:09 
2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="product"/>
+    </x>
+    <y>
+        <field name="profit"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 view/reporting_margin_product_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_list.xml    Sun Jan 17 00:57:09 2021 +0100
@@ -0,0 +1,14 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree keyword_open="1">
+    <field name="product" expand="1"/>
+    <field name="quantity" symbol="unit"/>
+    <field name="cost" symbol="currency"/>
+    <field name="revenue" symbol="currency"/>
+    <field name="profit" symbol="currency"/>
+    <field name="margin" factor="100">
+        <suffix string="%" name="margin"/>
+    </field>
+    <field name="margin_trend" expand="1"/>
+</tree>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_time_series_graph_amount.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_time_series_graph_amount.xml        Sun Jan 
17 00:57:09 2021 +0100
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="date"/>
+    </x>
+    <y>
+        <field name="cost"/>
+        <field name="revenue"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_time_series_graph_margin.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_time_series_graph_margin.xml        Sun Jan 
17 00:57:09 2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph type="line">
+    <x>
+        <field name="date"/>
+    </x>
+    <y>
+        <field name="margin" empty="0"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_time_series_graph_profit.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_time_series_graph_profit.xml        Sun Jan 
17 00:57:09 2021 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<graph>
+    <x>
+        <field name="date"/>
+    </x>
+    <y>
+        <field name="profit"/>
+    </y>
+</graph>
diff -r c9582871ec46 -r fcb961429f35 
view/reporting_margin_product_time_series_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/reporting_margin_product_time_series_list.xml        Sun Jan 17 
00:57:09 2021 +0100
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tree>
+    <field name="date"/>
+    <field name="quantity" symbol="unit"/>
+    <field name="cost" symbol="currency"/>
+    <field name="revenue" symbol="currency"/>
+    <field name="profit" symbol="currency"/>
+    <field name="margin" factor="100">
+        <suffix string="%" name="margin"/>
+    </field>
+</tree>

Reply via email to