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 @@
+
+
+
diff --git a/view/reporting_margin_product_graph_amount.xml b/view/reporting_margin_product_graph_amount.xml
new file mode 100644
index 0000000000..0ec1acd70a
--- /dev/null
+++ b/view/reporting_margin_product_graph_amount.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_graph_margin.xml b/view/reporting_margin_product_graph_margin.xml
new file mode 100644
index 0000000000..d5a5c7e528
--- /dev/null
+++ b/view/reporting_margin_product_graph_margin.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_graph_profit.xml b/view/reporting_margin_product_graph_profit.xml
new file mode 100644
index 0000000000..9d4be5809a
--- /dev/null
+++ b/view/reporting_margin_product_graph_profit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_list.xml b/view/reporting_margin_product_list.xml
new file mode 100644
index 0000000000..8eb43c1f16
--- /dev/null
+++ b/view/reporting_margin_product_list.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_time_series_graph_amount.xml b/view/reporting_margin_product_time_series_graph_amount.xml
new file mode 100644
index 0000000000..2f8746e2ea
--- /dev/null
+++ b/view/reporting_margin_product_time_series_graph_amount.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_time_series_graph_margin.xml b/view/reporting_margin_product_time_series_graph_margin.xml
new file mode 100644
index 0000000000..eb7c79959f
--- /dev/null
+++ b/view/reporting_margin_product_time_series_graph_margin.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_time_series_graph_profit.xml b/view/reporting_margin_product_time_series_graph_profit.xml
new file mode 100644
index 0000000000..b3dfec06fd
--- /dev/null
+++ b/view/reporting_margin_product_time_series_graph_profit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/reporting_margin_product_time_series_list.xml b/view/reporting_margin_product_time_series_list.xml
new file mode 100644
index 0000000000..688f41bd62
--- /dev/null
+++ b/view/reporting_margin_product_time_series_list.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+