Add margin reporting
issue9665 review292371002
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add margin reporting
|
||||
* Add products_by_locations Model
|
||||
|
||||
Version 5.8.0 - 2020-11-02
|
||||
|
||||
@@ -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,
|
||||
|
||||
+24
@@ -92,5 +92,29 @@ because the type does not support moves and location has existing moves.</field>
|
||||
<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>
|
||||
|
||||
@@ -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="""
|
||||
|
||||
@@ -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', '')),
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -19,3 +19,4 @@ xml:
|
||||
period.xml
|
||||
message.xml
|
||||
res.xml
|
||||
stock_reporting_margin.xml
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user