Files
tradon/modules/stock/stock_reporting.py
2026-03-14 09:42:12 +00:00

527 lines
17 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime as dt
from dateutil.relativedelta import relativedelta
from sql import Literal, Null, Window
from sql.aggregate import Min, Sum
from sql.conditionals import Case, Coalesce, Greatest, Least, NullIf
from sql.functions import Function, NthValue, Round
from sql.operators import Concat
from trytond import backend
from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.transaction import Transaction
class _SQLite_JulianDay(Function):
__slots__ = ()
_function = 'JULIANDAY'
class _InventoryContextMixin(ModelView):
company = fields.Many2One('company.company', "Company", required=True)
location = fields.Many2One(
'stock.location', "Location", required=True,
domain=[
('type', 'in', ['warehouse', 'storage', 'view']),
])
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_location(cls):
pool = Pool()
Location = pool.get('stock.location')
return Transaction().context.get(
'location', Location.get_default_warehouse())
class _InventoryMixin:
__slots__ = ()
company = fields.Many2One('company.company', "Company")
product = fields.Reference("Product", [
('product.product', "Variant"),
('product.template', "Product"),
])
unit = fields.Function(
fields.Many2One('product.uom', "Unit"),
'on_change_with_unit')
input_quantity = fields.Float("Input Quantity")
output_quantity = fields.Float("Output Quantity")
quantity = fields.Float("Quantity")
@classmethod
def table_query(cls):
pool = Pool()
Location = pool.get('stock.location')
Move = pool.get('stock.move')
Period = pool.get('stock.period')
PeriodCache = pool.get('stock.period.cache')
Product = pool.get('product.product')
transaction = Transaction()
context = transaction.context
move = Move.__table__()
from_location = Location.__table__()
to_location = Location.__table__()
period_cache = PeriodCache.__table__()
product_table = Product.__table__()
if location := context.get('location'):
location = Location(location)
left, right = location.left, location.right
else:
left = right = -1
if date := context.get('date'):
from_date = to_date = date
else:
from_date = context.get('from_date') or dt.date.min
to_date = context.get('to_date') or dt.date.max
company = context.get('company')
if periods := Period.search([
('company', '=', company),
('date', '<=', from_date),
('state', '=', 'closed'),
],
order=[('date', 'DESC')],
limit=1):
period, = periods
else:
period = None
quantities = (
move
.join(from_location,
condition=move.from_location == from_location.id)
.join(to_location,
condition=move.to_location == to_location.id)
)
if context.get('product_type') == 'product.product':
product = Concat('product.product,', move.product)
else:
product = Concat('product.template,', product_table.template)
quantities = (
quantities
.join(product_table,
condition=move.product == product_table.id)
)
move_date = Coalesce(move.effective_date, move.planned_date)
input_clause = (
(to_location.left >= left) & (to_location.right <= right))
output_clause = (
(from_location.left >= left) & (from_location.right <= right))
start_quantity = Sum(
move.quantity * Case((output_clause, -1), else_=1),
filter_=(move_date < from_date))
input_quantity = Sum(
move.quantity,
filter_=input_clause & (move_date >= from_date))
output_quantity = Sum(
move.quantity,
filter_=output_clause & (move_date >= from_date))
date_column = Greatest(move_date, from_date)
if context.get('product_type') == 'product.product':
partition = [move.product]
else:
partition = [product_table.template]
# Use NthValue instead of LastValue to get NULL for the last row
next_date_column = NthValue(
date_column, 2, window=Window(
partition,
order_by=[date_column.asc],
frame='ROWS', start=0, end=1))
state_clause = cls._state_clause(move, to_date)
quantities = quantities.select(
Min(move.id).as_('id'),
product.as_('product'),
date_column.as_('date'),
next_date_column.as_('next_date'),
start_quantity.as_('start_quantity'),
input_quantity.as_('input_quantity'),
output_quantity.as_('output_quantity'),
*cls._quantities_columns(move, product_table),
where=(((input_clause & ~output_clause)
| (output_clause & ~input_clause))
& (move_date <= to_date)
& state_clause
& (move.company == company)),
group_by=[
*partition,
date_column,
*cls._quantities_group_by(move, product_table),
],
)
quantity = Sum(
Coalesce(quantities.start_quantity, 0)
+ Coalesce(quantities.input_quantity, 0)
- Coalesce(quantities.output_quantity, 0),
window=Window(
[quantities.product],
order_by=[*cls._quantities_order_by(quantities)]))
if period:
quantities.where &= move_date > period.date
if context.get('product_type') != 'product.product':
period_cache_product = Product.__table__()
cache = (
period_cache
.join(period_cache_product,
condition=period_cache.product
== period_cache_product.id)
.select(
period_cache_product.template.as_('product'),
Sum(period_cache.internal_quantity
).as_('quantity'),
group_by=[period_cache_product.template]))
else:
cache = (
period_cache
.select(
period_cache.product.as_('product'),
Sum(period_cache.internal_quantity).as_('quantity'),
group_by=[period_cache.product]))
location_cache = Location.__table__()
cache.where = (
(period_cache.period == period.id)
& period_cache.location.in_(
location_cache.select(
location_cache.id,
where=(location_cache.left >= left)
& (location_cache.right <= right))))
query = quantities.join(cache, 'LEFT',
condition=(
cache.product
== cls.product.sql_id(quantities.product, cls)))
quantity += Coalesce(cache.quantity, 0)
else:
query = quantities
return (query
.select(
quantities.id.as_('id'),
Literal(company).as_('company'),
quantities.product.as_('product'),
quantities.input_quantity.as_('input_quantity'),
quantities.output_quantity.as_('output_quantity'),
quantity.as_('quantity'),
*cls._columns(quantities),
where=(
(Coalesce(quantities.next_date, dt.date.max) >= from_date)
| (quantities.date >= from_date))))
@classmethod
def _quantities_columns(cls, move, product):
yield from []
@classmethod
def _quantities_group_by(cls, move, product):
yield from []
@classmethod
def _quantities_order_by(cls, quantities):
yield quantities.date.asc
@classmethod
def _columns(cls, quantities):
yield from []
@classmethod
def _where(cls, quantities):
return Literal(True)
@classmethod
def _state_clause(cls, move, date):
pool = Pool()
Date = pool.get('ir.date')
today = Date.today()
forcast = date > today
move_date = Coalesce(move.effective_date, move.planned_date)
state_clause = (
(move_date <= today) & (move.state == 'done'))
state_clause |= (
((move_date >= today) if forcast else (move_date > today))
& (move.state.in_(['done', 'assigned', 'draft'])))
return state_clause
@fields.depends('product')
def on_change_with_unit(self, name=None):
if self.product:
return self.product.default_uom
class InventoryContext(_InventoryContextMixin):
__name__ = 'stock.reporting.inventory.context'
date = fields.Date("Date", required=True)
product_type = fields.Selection([
('product.product', "Variant"),
('product.template', "Product"),
], "Product Type", required=True)
@classmethod
def default_date(cls):
return Pool().get('ir.date').today()
@classmethod
def default_product_type(cls):
return Transaction().context.get('product_type') or 'product.template'
class Inventory(_InventoryMixin, ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory'
class _InventoryRangeContextMixin(_InventoryContextMixin):
from_date = fields.Date(
"From Date", required=True,
domain=[
('from_date', '<=', Eval('to_date')),
])
to_date = fields.Date(
"To Date", required=True,
domain=[
('to_date', '>=', Eval('from_date')),
])
@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(day=1, months=6)
@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() + relativedelta(day=1, months=6)
class InventoryRangeContext(_InventoryRangeContextMixin):
__name__ = 'stock.reporting.inventory.range.context'
class InventoryMove(_InventoryMixin, ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory.move'
date = fields.Date("Date")
move = fields.Many2One('stock.move', "Move")
origin = fields.Reference("Origin", selection='get_origin')
document = fields.Function(
fields.Reference("Document", selection='get_documents'),
'get_document')
@classmethod
def __setup__(cls):
super().__setup__()
cls._order = [
('date', 'DESC'),
('move', 'DESC NULLS LAST'),
('id', 'DESC'),
]
@classmethod
def _quantities_columns(cls, move, product):
from_date = Transaction().context.get('from_date') or dt.date.min
move_date = Coalesce(move.effective_date, move.planned_date)
yield from super()._quantities_columns(move, product)
yield Case(
(move_date < from_date, Null),
else_=move.id).as_('move')
yield Case(
(move_date < from_date, Null),
else_=move.origin).as_('origin')
@classmethod
def _quantities_order_by(cls, quantities):
yield from super()._quantities_order_by(quantities)
yield quantities.move.asc.nulls_first
@classmethod
def _quantities_group_by(cls, move, product):
from_date = Transaction().context.get('from_date') or dt.date.min
move_date = Coalesce(move.effective_date, move.planned_date)
yield from super()._quantities_group_by(move, product)
yield Case(
(move_date < from_date, Null),
else_=move.id)
yield Case(
(move_date < from_date, Null),
else_=move.origin)
@classmethod
def _columns(cls, quantities):
yield from super()._columns(quantities)
yield quantities.date.as_('date')
yield quantities.move.as_('move')
yield quantities.origin.as_('origin')
@classmethod
def get_origin(cls):
pool = Pool()
Move = pool.get('stock.move')
return Move.get_origin()
@classmethod
def _get_document_models(cls):
pool = Pool()
Move = pool.get('stock.move')
return [m for m, _ in Move.get_shipment() if m]
@classmethod
def get_documents(cls):
pool = Pool()
Model = pool.get('ir.model')
get_name = Model.get_name
models = cls._get_document_models()
return [(None, '')] + [(m, get_name(m)) for m in models]
def get_document(self, name):
if self.move and self.move.shipment:
return str(self.move.shipment)
def get_rec_name(self, name):
name = super().get_rec_name(name)
if self.move:
name = self.move.rec_name
return name
class InventoryDaily(_InventoryMixin, ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory.daily'
from_date = fields.Date("From Date")
to_date = fields.Date("To Date")
@classmethod
def __setup__(cls):
super().__setup__()
cls._order = [
('from_date', 'DESC'),
('id', 'DESC'),
]
@classmethod
def _quantities_order_by(cls, quantities):
yield from []
yield quantities.date.asc
@classmethod
def _columns(cls, quantities):
transaction = Transaction()
context = transaction.context
to_date = context.get('to_date') or dt.date.max
yield from super()._columns(quantities)
yield quantities.date.as_('from_date')
yield Least(quantities.next_date, to_date).as_('to_date')
class InventoryTurnoverContext(_InventoryRangeContextMixin):
__name__ = 'stock.reporting.inventory.turnover.context'
product_type = fields.Selection([
('product.product', "Variant"),
('product.template', "Product"),
], "Product Type", required=True)
@classmethod
def default_product_type(cls):
return Transaction().context.get('product_type') or 'product.template'
class InventoryTurnover(ModelSQL, ModelView):
__name__ = 'stock.reporting.inventory.turnover'
company = fields.Many2One('company.company', "Company")
product = fields.Reference("Product", [
('product.product', "Variant"),
('product.template', "Product"),
])
unit = fields.Function(
fields.Many2One('product.uom', "Unit"),
'on_change_with_unit')
output_quantity = fields.Float("Output Quantity", digits=(None, 3))
average_quantity = fields.Float("Average Quantity", digits=(None, 3))
turnover = fields.Float("Turnover", digits=(None, 3))
@classmethod
def table_query(cls):
pool = Pool()
Inventory = pool.get('stock.reporting.inventory.daily')
transaction = Transaction()
context = transaction.context
inventory = Inventory.__table__()
from_date = context.get('from_date') or dt.date.min
to_date = context.get('to_date') or dt.date.max
company = context.get('company')
days = (to_date - from_date).days + 1
output_quantity = Sum(inventory.output_quantity) / days
inventory_from_date = inventory.from_date
inventory_to_date = inventory.to_date
if backend.name == 'sqlite':
inventory_from_date = _SQLite_JulianDay(inventory_from_date)
inventory_to_date = _SQLite_JulianDay(inventory_to_date)
average_quantity = (
Sum(Case(
(inventory.quantity >= 0, inventory.quantity),
else_=0)
* (inventory_to_date - inventory_from_date
+ Case((inventory.to_date == to_date, 1), else_=0)))
/ days)
def round_sql(expression, digits=2):
factor = 10 ** digits
return Round(expression * factor) / factor
return (inventory
.select(
cls.product.sql_id(inventory.product, cls).as_('id'),
Literal(company).as_('company'),
inventory.product.as_('product'),
round_sql(
output_quantity,
cls.output_quantity.digits[1]).as_('output_quantity'),
round_sql(
average_quantity,
cls.average_quantity.digits[1]).as_('average_quantity'),
round_sql(
output_quantity / NullIf(average_quantity, 0),
cls.turnover.digits[1]).as_('turnover'),
group_by=[inventory.product]))
@fields.depends('product')
def on_change_with_unit(self, name=None):
if self.product:
return self.product.default_uom