[IMP] stock{_account}: add (product) stock report
Adds a new reporting view for [storable] product.product to allow for
easier analysis by product (rather than by quant which is location
specific).
This includes:
- adding 3 new fields for products:
- avg_cost = total svl value / total qty
- total_value = avg_cost / on hand
- company_currency_id = self.env.company.currency_id (i.e. the
currently selected company) for which currency symbol to display in
the view
- IMPORTANT NOTE:
- these variables (and some of the existing ones) do NOT support
multi-company aggregations due these values being used to calcuate
future svls which should be company specific. Larger refactoring
would be needed to ensure correct calc in both cases. Larger issue
occurs in stock.report which uses these variables in combination
with variables that do take into account multi-company records.
- A customized `SearchPanel` so that a warehouse context can be applied
to the view, rather than a filter. (viewable only when more than 1
warehouse)
"stock report" part of b2b task: 2882539
Part-of: odoo/odoo#97109
This commit is contained in:
@@ -69,15 +69,15 @@
|
||||
|
||||
'views/res_partner_views.xml',
|
||||
'views/product_strategy_views.xml',
|
||||
'views/product_views.xml',
|
||||
'views/stock_lot_views.xml',
|
||||
'views/stock_scrap_views.xml',
|
||||
'views/stock_quant_views.xml',
|
||||
'views/stock_location_views.xml',
|
||||
'views/stock_warehouse_views.xml',
|
||||
'views/stock_move_line_views.xml',
|
||||
'views/stock_picking_views.xml',
|
||||
'views/stock_move_views.xml',
|
||||
'views/product_views.xml',
|
||||
'views/stock_location_views.xml',
|
||||
'views/stock_orderpoint_views.xml',
|
||||
'views/stock_storage_category_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
@@ -103,24 +103,8 @@
|
||||
'stock/static/src/scss/stock_traceability_report.scss',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'stock/static/src/js/inventory_report_list_controller.js',
|
||||
'stock/static/src/js/inventory_report_list_view.js',
|
||||
'stock/static/src/js/inventory_singleton_list_renderer.js',
|
||||
'stock/static/src/js/inventory_singleton_list_controller.js',
|
||||
'stock/static/src/js/inventory_singleton_list_view.js',
|
||||
'stock/static/src/js/report_stock_forecasted.js',
|
||||
'stock/static/src/js/report_stock_reception.js',
|
||||
'stock/static/src/js/lazy_column_list.js',
|
||||
'stock/static/src/js/stock_orderpoint_list_controller.js',
|
||||
'stock/static/src/js/stock_orderpoint_list_model.js',
|
||||
'stock/static/src/js/stock_orderpoint_list_view.js',
|
||||
'stock/static/src/js/stock_traceability_report_backend.js',
|
||||
'stock/static/src/js/stock_traceability_report_widgets.js',
|
||||
'stock/static/src/js/popover_widget.js',
|
||||
'stock/static/src/js/forecast_widget.js',
|
||||
'stock/static/src/js/counted_quantity_widget.js',
|
||||
'stock/static/src/js/basic_model.js',
|
||||
'stock/static/src/js/stock_rescheduling_popover.js',
|
||||
'stock/static/src/**/*.js',
|
||||
'stock/static/src/views/**/*.js',
|
||||
'stock/static/src/scss/forecast_widget.scss',
|
||||
'stock/static/src/scss/stock_empty_screen.scss',
|
||||
],
|
||||
@@ -134,15 +118,8 @@
|
||||
'stock/static/tests/stock_traceability_report_backend_tests.js',
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
'stock/static/src/xml/inventory_report.xml',
|
||||
'stock/static/src/xml/inventory_adjustments.xml',
|
||||
'stock/static/src/xml/inventory_lines.xml',
|
||||
'stock/static/src/xml/popover_widget.xml',
|
||||
'stock/static/src/xml/forecast_widget.xml',
|
||||
'stock/static/src/xml/report_stock_forecasted.xml',
|
||||
'stock/static/src/xml/report_stock_reception.xml',
|
||||
'stock/static/src/xml/stock_orderpoint.xml',
|
||||
'stock/static/src/xml/stock_traceability_report_backend.xml',
|
||||
'stock/static/src/**/*.xml',
|
||||
'stock/static/src/views/**/*.xml',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -516,7 +516,6 @@ class Product(models.Model):
|
||||
|
||||
# Be aware that the exact same function exists in product.template
|
||||
def action_open_quants(self):
|
||||
domain = [('product_id', 'in', self.ids)]
|
||||
hide_location = not self.user_has_groups('stock.group_stock_multi_locations')
|
||||
hide_lot = all(product.tracking == 'none' for product in self)
|
||||
self = self.with_context(
|
||||
@@ -544,8 +543,10 @@ class Product(models.Model):
|
||||
else:
|
||||
self = self.with_context(product_tmpl_ids=self.product_tmpl_id.ids)
|
||||
action = self.env['stock.quant'].action_view_inventory()
|
||||
action['domain'] = domain
|
||||
action["name"] = _('Update Quantity')
|
||||
# note that this action is used by different views w/varying customizations
|
||||
if not self.env.context.get('is_stock_report'):
|
||||
action['domain'] = [('product_id', 'in', self.ids)]
|
||||
action["name"] = _('Update Quantity')
|
||||
return action
|
||||
|
||||
def action_update_quantity_on_hand(self):
|
||||
|
||||
@@ -1074,3 +1074,6 @@ class Warehouse(models.Model):
|
||||
'limit': 20,
|
||||
'context': dict(self._context, default_warehouse_selectable=True, default_warehouse_ids=self.ids)
|
||||
}
|
||||
|
||||
def get_current_warehouses(self):
|
||||
return self.env['stock.warehouse'].search_read(fields=['id', 'name', 'code'], order='name')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { listView } from '@web/views/list/list_view';
|
||||
import { registry } from "@web/core/registry";
|
||||
import { StockReportSearchModel } from "../search/stock_report_search_model";
|
||||
import { StockReportSearchPanel } from '../search/stock_report_search_panel';
|
||||
|
||||
|
||||
export const StockReportListView = {
|
||||
...listView,
|
||||
SearchModel: StockReportSearchModel,
|
||||
SearchPanel: StockReportSearchPanel,
|
||||
};
|
||||
|
||||
registry.category("views").add("stock_report_list_view", StockReportListView);
|
||||
@@ -0,0 +1,48 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
|
||||
export class StockReportSearchModel extends SearchModel {
|
||||
|
||||
async load() {
|
||||
await super.load(...arguments);
|
||||
await this._loadWarehouses();
|
||||
}
|
||||
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
getWarehouses() {
|
||||
return this.warehouses;
|
||||
}
|
||||
|
||||
async _loadWarehouses() {
|
||||
this.warehouses = await this.orm.call(
|
||||
'stock.warehouse',
|
||||
'get_current_warehouses',
|
||||
[[]],
|
||||
{ context: this.context },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the context of a warehouse so values calculate based on all possible
|
||||
* warehouses
|
||||
*/
|
||||
clearWarehouseContext() {
|
||||
delete this.globalContext.warehouse;
|
||||
this._notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} warehouse_id
|
||||
* Sets the context to the selected warehouse so values that take this into account
|
||||
* will recalculate based on this without filtering out any records
|
||||
*/
|
||||
applyWarehouseContext(warehouse_id) {
|
||||
this.globalContext['warehouse'] = warehouse_id;
|
||||
this._notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { SearchPanel } from "@web/search/search_panel/search_panel";
|
||||
|
||||
export class StockReportSearchPanel extends SearchPanel {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.selectedWarehouse = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
get warehouses() {
|
||||
return this.env.searchModel.getWarehouses();
|
||||
}
|
||||
|
||||
clearWarehouseContext() {
|
||||
this.env.searchModel.clearWarehouseContext();
|
||||
this.selectedWarehouse = null;
|
||||
}
|
||||
|
||||
applyWarehouseContext(warehouse_id) {
|
||||
this.env.searchModel.applyWarehouseContext(warehouse_id);
|
||||
this.selectedWarehouse = warehouse_id;
|
||||
}
|
||||
}
|
||||
|
||||
StockReportSearchPanel.template = "stock.StockReportSearchPanel";
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="stock.StockReportSearchPanel" t-inherit="web.SearchPanel" owl="1">
|
||||
<xpath expr="//div/section[1]" position="before">
|
||||
<section t-if="warehouses.length > 1" class="o_search_panel_section o_search_panel_warehouse">
|
||||
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase o_cursor_default">
|
||||
<i t-attf-class="fa fa-filter o_search_panel_section_icon text-warning me-2"/>
|
||||
<b>Warehouses</b>
|
||||
</header>
|
||||
<ul class="list-group d-block o_search_panel_field">
|
||||
<li class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer">
|
||||
<span t-on-click="ev => this.clearWarehouseContext(ev)" t-att-class="{'fw-bold': !selectedWarehouse}">All Warehouses</span>
|
||||
</li>
|
||||
<li class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer"
|
||||
t-foreach="warehouses" t-as="warehouse" t-key="warehouse.id">
|
||||
<span t-out="warehouse.name" t-on-click="ev => this.applyWarehouseContext(warehouse.id, ev)"
|
||||
t-att-class="{'fw-bold': selectedWarehouse === warehouse.id}"/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -450,6 +450,74 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Stock Report View -->
|
||||
<record model="ir.ui.view" id="product_product_stock_tree">
|
||||
<field name="name">product.product.stock.tree</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="priority" eval="100"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree sample="1" js_class="stock_report_list_view">
|
||||
<field name="id" invisible="1"/>
|
||||
<field name="display_name" string="Product"/>
|
||||
<field name="categ_id" optional="hide"/>
|
||||
<field name="qty_available" string="On Hand" sum="Total On Hand"/>
|
||||
<button name="%(action_view_inventory_tree)d" title="Inventory Adjustment" type="action" class="btn-link"
|
||||
icon="fa-pencil" context="{'search_default_product_id': id, 'default_product_id': id}"/>
|
||||
<field name="free_qty" string="Free to Use" sum="Total Free to Use"/>
|
||||
<field name="incoming_qty" optional="show" sum="Total Incoming"/>
|
||||
<field name="outgoing_qty" optional="show" sum="Total Outgoing"/>
|
||||
<field name="virtual_available" string="Forecasted" optional="hide" sum="Total Forecasted"/>
|
||||
<field name="uom_id" string="Unit" groups="uom.group_uom" options="{'no_create': True}"/>
|
||||
<button name="%(stock_move_line_action)d" string="History" type="action" class="btn-link"
|
||||
icon="fa-history" context="{'search_default_product_id': id, 'default_product_id': id}"/>
|
||||
<button name="action_view_orderpoints" string="Replenishment" type="object" class="btn-link"
|
||||
icon="fa-refresh" context="{'search_default_product_id': id, 'is_stock_report': True}" />
|
||||
<button name="%(action_view_quants)d" string="Locations" type="action" class="btn-link" groups="stock.group_stock_multi_locations"
|
||||
icon="fa-cubes" context="{'search_default_product_id': id, 'default_product_id': id}" attrs="{'invisible': [('qty_available', '=', 0)]}"/>
|
||||
<button name="action_product_forecast_report" string="Forecast" type="object" class="btn-link"
|
||||
icon="fa-area-chart" context="{'default_product_id': id}" attrs="{'invisible': [('incoming_qty', '=', 0), ('outgoing_qty', '=', 0)]}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_search_form_view_stock_report" model="ir.ui.view">
|
||||
<field name="name">product.product.search.stock.form.stock.report</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="stock_product_search_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<filter name='services' position="attributes">
|
||||
<attribute name="attrs">{'invisible': 1}</attribute>
|
||||
</filter>
|
||||
<filter name='consumable' position="attributes">
|
||||
<attribute name="attrs">{'invisible': 1}</attribute>
|
||||
</filter>
|
||||
<filter name="real_stock_negative" position="after">
|
||||
<searchpanel>
|
||||
<field name="categ_id" icon="fa-filter" string="Category" select="multi"/>
|
||||
</searchpanel>
|
||||
</filter>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.act_window" id="action_product_stock_view">
|
||||
<field name="name">Stock</field>
|
||||
<field name="res_model">product.product</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="product_product_stock_tree"/>
|
||||
<field name="search_view_id" ref="product_search_form_view_stock_report"/>
|
||||
<field name="context">{'default_detailed_type': 'product'}</field>
|
||||
<field name="domain">[('detailed_type', '=', 'product')]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No stock to show
|
||||
</p><p>
|
||||
Create some storable products to see their stock info in this view.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="product_template_action_product" model="ir.actions.act_window">
|
||||
<field name="name">Products</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
@@ -479,6 +547,8 @@
|
||||
<menuitem id="product_product_menu" name="Product Variants" action="stock_product_normal_action"
|
||||
parent="menu_stock_inventory_control" sequence="2" groups="product.group_product_variant"/>
|
||||
<menuitem id="menu_product_packagings" name="Product Packagings" parent="stock.menu_product_in_config_stock" action="product.action_packaging_view" groups="product.group_stock_packaging"/>
|
||||
<menuitem id="menu_product_stock" name="Stock" action="stock.action_product_stock_view"
|
||||
parent="stock.menu_warehouse_report" sequence="5"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="product_category_id" invisible="1"/>
|
||||
<field name="product_tmpl_id" invisible="1"/>
|
||||
<field name="product_id" attrs="{'readonly': [('product_id', '!=', False)]}" invisible="context.get('default_product_id')" force_save="1"/>
|
||||
<field name="product_id" attrs="{'readonly': [('product_id', '!=', False)]}" force_save="1"/>
|
||||
<field name="location_id" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/>
|
||||
<field name="warehouse_id" options="{'no_create': True}" groups="stock.group_stock_multi_warehouses" optional="hide"/>
|
||||
<field name="qty_on_hand" force_save="1"/>
|
||||
|
||||
@@ -29,10 +29,10 @@ Dashboard / Reports for Warehouse Management includes:
|
||||
'views/stock_account_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'data/product_data.xml',
|
||||
'views/product_views.xml',
|
||||
'views/report_invoice.xml',
|
||||
'views/stock_valuation_layer_views.xml',
|
||||
'views/stock_quant_views.xml',
|
||||
'views/product_views.xml',
|
||||
'wizard/stock_request_count.xml',
|
||||
'wizard/stock_valuation_layer_revaluation_views.xml',
|
||||
'wizard/stock_quantity_history.xml',
|
||||
|
||||
@@ -96,6 +96,12 @@ class ProductProduct(models.Model):
|
||||
|
||||
value_svl = fields.Float(compute='_compute_value_svl', compute_sudo=True)
|
||||
quantity_svl = fields.Float(compute='_compute_value_svl', compute_sudo=True)
|
||||
avg_cost = fields.Monetary(string="Average Cost", compute='_compute_value_svl', compute_sudo=True, currency_field='company_currency_id')
|
||||
total_value = fields.Monetary(string="Total Value", compute='_compute_value_svl', compute_sudo=True, currency_field='company_currency_id')
|
||||
company_currency_id = fields.Many2one(
|
||||
'res.currency', 'Valuation Currency', compute='_compute_value_svl', compute_sudo=True,
|
||||
help="Technical field to correctly show the currently selected company's currency that corresponds "
|
||||
"to the totaled value of the product's valuation layers")
|
||||
stock_valuation_layer_ids = fields.One2many('stock.valuation.layer', 'product_id')
|
||||
valuation = fields.Selection(related="categ_id.property_valuation", readonly=True)
|
||||
cost_method = fields.Selection(related="categ_id.property_cost_method", readonly=True)
|
||||
@@ -108,11 +114,12 @@ class ProductProduct(models.Model):
|
||||
@api.depends('stock_valuation_layer_ids')
|
||||
@api.depends_context('to_date', 'company')
|
||||
def _compute_value_svl(self):
|
||||
"""Compute `value_svl` and `quantity_svl`."""
|
||||
company_id = self.env.company.id
|
||||
"""Compute totals of multiple svl related values"""
|
||||
company_id = self.env.company
|
||||
self.company_currency_id = company_id.currency_id
|
||||
domain = [
|
||||
('product_id', 'in', self.ids),
|
||||
('company_id', '=', company_id),
|
||||
('company_id', '=', company_id.id),
|
||||
]
|
||||
if self.env.context.get('to_date'):
|
||||
to_date = fields.Datetime.to_datetime(self.env.context['to_date'])
|
||||
@@ -121,12 +128,18 @@ class ProductProduct(models.Model):
|
||||
products = self.browse()
|
||||
for group in groups:
|
||||
product = self.browse(group['product_id'][0])
|
||||
product.value_svl = self.env.company.currency_id.round(group['value'])
|
||||
value_svl = company_id.currency_id.round(group['value'])
|
||||
avg_cost = value_svl / group['quantity'] if group['quantity'] else 0
|
||||
product.value_svl = value_svl
|
||||
product.quantity_svl = group['quantity']
|
||||
product.avg_cost = avg_cost
|
||||
product.total_value = avg_cost * product.sudo(False).qty_available
|
||||
products |= product
|
||||
remaining = (self - products)
|
||||
remaining.value_svl = 0
|
||||
remaining.quantity_svl = 0
|
||||
remaining.avg_cost = 0
|
||||
remaining.total_value = 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Actions
|
||||
|
||||
@@ -45,5 +45,22 @@
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Stock Report View -->
|
||||
<record model="ir.ui.view" id="product_product_stock_tree_inherit_stock_account">
|
||||
<field name="name">product.product.stock.tree.inherit.stock.account</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="inherit_id" ref="stock.product_product_stock_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="qty_available" position="before">
|
||||
<field name="company_currency_id" invisible="1"/>
|
||||
<field name="cost_method" invisible="1"/>
|
||||
<field name="avg_cost" string="Unit Cost" optional="show" widget='monetary' options="{'currency_field': 'company_currency_id'}"/>
|
||||
<field name="total_value" string="Total Value" optional="show" widget='monetary' options="{'currency_field': 'company_currency_id'}" sum="Total Value"/>
|
||||
<button name="%(stock_valuation_layer_action)d" title="Valuation Report" type="action" class="btn-link"
|
||||
icon="fa-bar-chart" context="{'search_default_product_id': id, 'default_product_id': id}" attrs="{'invisible': [('cost_method', '!=', 'fifo')]}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user