diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index e90a4f62411..e27718a9b4c 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -18,6 +18,9 @@ from erpnext.accounts.general_ledger import ( from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController from erpnext.stock import get_warehouse_account_map +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( + get_evaluated_inventory_dimension, +) from erpnext.stock.stock_ledger import get_items_to_be_repost @@ -364,8 +367,16 @@ class StockController(AccountsController): ) sl_dict.update(args) + self.update_inventory_dimensions(d, sl_dict) + return sl_dict + def update_inventory_dimensions(self, row, sl_dict) -> None: + dimensions = get_evaluated_inventory_dimension(row, sl_dict, parent_doc=self) + for dimension in dimensions: + if dimension and row.get(dimension.source_fieldname): + sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) + def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): from erpnext.stock.stock_ledger import make_sl_entries diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 51139b31af5..f2bea5829c2 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -213,6 +213,32 @@ $.extend(erpnext.utils, { }); }, + add_inventory_dimensions: function(report_name, index) { + let filters = frappe.query_reports[report_name].filters; + + frappe.call({ + method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_inventory_dimensions", + callback: function(r) { + if (r.message && r.message.length) { + r.message.forEach((dimension) => { + let found = filters.some(el => el.fieldname === dimension['fieldname']); + + if (!found) { + filters.splice(index, 0, { + "fieldname": dimension["fieldname"], + "label": __(dimension["label"]), + "fieldtype": "MultiSelectList", + get_data: function(txt) { + return frappe.db.get_link_options(dimension["doctype"], txt); + }, + }); + } + }); + } + } + }); + }, + make_subscription: function(doctype, docname) { frappe.call({ method: "frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat", diff --git a/erpnext/stock/doctype/inventory_dimension/__init__.py b/erpnext/stock/doctype/inventory_dimension/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js new file mode 100644 index 00000000000..91a21f4e722 --- /dev/null +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js @@ -0,0 +1,48 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Inventory Dimension', { + setup(frm) { + frm.trigger('set_query_on_fields'); + }, + + set_query_on_fields(frm) { + frm.set_query('reference_document', () => { + let invalid_doctypes = frappe.model.core_doctypes_list; + invalid_doctypes.push('Batch', 'Serial No', 'Warehouse', 'Item', 'Inventory Dimension', + 'Accounting Dimension', 'Accounting Dimension Filter'); + + return { + filters: { + 'istable': 0, + 'issingle': 0, + 'name': ['not in', invalid_doctypes] + } + }; + }); + + frm.set_query('document_type', () => { + return { + query: 'erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_inventory_documents', + }; + }); + }, + + onload(frm) { + frm.trigger('render_traget_field'); + }, + + refresh(frm) { + if (frm.doc.__onload && frm.doc.__onload.has_stock_ledger + && frm.doc.__onload.has_stock_ledger.length) { + let msg = __('Stock transactions exists against this dimension, user can not update document.'); + frm.dashboard.add_comment(msg, 'blue', true); + + frm.fields.forEach((field) => { + if (field.df.fieldname !== 'disabled') { + frm.set_df_property(field.df.fieldname, "read_only", "1"); + } + }); + } + } +}); diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json new file mode 100644 index 00000000000..8b334d13d7d --- /dev/null +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json @@ -0,0 +1,194 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:dimension_name", + "creation": "2022-06-17 13:04:16.554051", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "dimension_details_tab", + "dimension_name", + "reference_document", + "column_break_4", + "disabled", + "section_break_7", + "field_mapping_section", + "source_fieldname", + "column_break_9", + "target_fieldname", + "applicable_for_documents_tab", + "apply_to_all_doctypes", + "document_type", + "istable", + "type_of_transaction", + "column_break_16", + "condition", + "applicable_condition_example_section", + "html_19" + ], + "fields": [ + { + "fieldname": "dimension_details_tab", + "fieldtype": "Tab Break", + "label": "Dimension Details" + }, + { + "fieldname": "reference_document", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference Document", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "dimension_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Dimension Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "applicable_for_documents_tab", + "fieldtype": "Tab Break", + "label": "Applicable For Documents" + }, + { + "depends_on": "eval:!doc.apply_to_all_doctypes", + "fieldname": "document_type", + "fieldtype": "Link", + "label": "Applicable to Document", + "mandatory_depends_on": "eval:!doc.apply_to_all_doctypes", + "options": "DocType" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.apply_to_all_doctypes && doc.document_type", + "fetch_from": "document_type.istable", + "fieldname": "istable", + "fieldtype": "Check", + "label": " Is Child Table", + "read_only": 1 + }, + { + "depends_on": "eval:!doc.apply_to_all_doctypes", + "fieldname": "condition", + "fieldtype": "Code", + "label": "Applicable Condition" + }, + { + "default": "0", + "fieldname": "apply_to_all_doctypes", + "fieldtype": "Check", + "label": "Apply to All Inventory Document Types" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break" + }, + { + "fieldname": "target_fieldname", + "fieldtype": "Data", + "label": "Target Fieldname (Stock Ledger Entry)", + "read_only": 1 + }, + { + "fieldname": "source_fieldname", + "fieldtype": "Data", + "label": "Source Fieldname", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "field_mapping_section", + "fieldtype": "Section Break", + "label": "Field Mapping" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "type_of_transaction", + "fieldtype": "Select", + "label": "Type of Transaction", + "options": "\nInward\nOutward" + }, + { + "fieldname": "html_19", + "fieldtype": "HTML", + "options": "\n\n \n \n \n \n\n\n\n \n \n\n\n \n \n\n\n\n
Child DocumentNon Child Document
\n

To access parent document field use parent.fieldname and to access child table document field use doc.fieldname

\n\n
\n

To access document field use doc.fieldname

\n
\n

Example: parent.doctype == \"Stock Entry\" and doc.item_code == \"Test\"

\n\n
\n

Example: doc.doctype == \"Stock Entry\" and doc.purpose == \"Manufacture\"

\n
\n\n\n\n\n\n\n" + }, + { + "collapsible": 1, + "depends_on": "eval:!doc.apply_to_all_doctypes", + "fieldname": "applicable_condition_example_section", + "fieldtype": "Section Break", + "label": "Applicable Condition Examples" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2022-07-19 21:06:11.824976", + "modified_by": "Administrator", + "module": "Stock", + "name": "Inventory Dimension", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Stock User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py new file mode 100644 index 00000000000..5a9541f060a --- /dev/null +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -0,0 +1,196 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, bold, scrub +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.model.document import Document + + +class DoNotChangeError(frappe.ValidationError): + pass + + +class CanNotBeChildDoc(frappe.ValidationError): + pass + + +class CanNotBeDefaultDimension(frappe.ValidationError): + pass + + +class InventoryDimension(Document): + def onload(self): + if not self.is_new() and frappe.db.has_column("Stock Ledger Entry", self.target_fieldname): + self.set_onload("has_stock_ledger", self.has_stock_ledger()) + + def has_stock_ledger(self) -> str: + if not self.target_fieldname: + return + + return frappe.get_all( + "Stock Ledger Entry", filters={self.target_fieldname: ("is", "set"), "is_cancelled": 0}, limit=1 + ) + + def validate(self): + self.do_not_update_document() + self.reset_value() + self.validate_reference_document() + self.set_source_and_target_fieldname() + + def do_not_update_document(self): + if self.is_new() or not self.has_stock_ledger(): + return + + old_doc = self._doc_before_save + for field in frappe.get_meta("Inventory Dimension").fields: + if field.fieldname != "disabled" and old_doc.get(field.fieldname) != self.get(field.fieldname): + msg = f"""The user can not change value of the field {bold(field.label)} because + stock transactions exists against the dimension {bold(self.name)}.""" + + frappe.throw(_(msg), DoNotChangeError) + + def reset_value(self): + if self.apply_to_all_doctypes: + self.istable = 0 + for field in ["document_type", "condition"]: + self.set(field, None) + + def validate_reference_document(self): + if frappe.get_cached_value("DocType", self.reference_document, "istable") == 1: + msg = f"The reference document {self.reference_document} can not be child table." + frappe.throw(_(msg), CanNotBeChildDoc) + + if self.reference_document in ["Batch", "Serial No", "Warehouse", "Item"]: + msg = f"The reference document {self.reference_document} can not be an Inventory Dimension." + frappe.throw(_(msg), CanNotBeDefaultDimension) + + def set_source_and_target_fieldname(self) -> None: + if not self.source_fieldname: + self.source_fieldname = scrub(self.dimension_name) + + if not self.target_fieldname: + self.target_fieldname = scrub(self.reference_document) + + def on_update(self): + self.add_custom_fields() + + def add_custom_fields(self): + dimension_field = dict( + fieldname=self.source_fieldname, + fieldtype="Link", + insert_after="warehouse", + options=self.reference_document, + label=self.dimension_name, + ) + + custom_fields = {} + + if self.apply_to_all_doctypes: + for doctype in get_inventory_documents(): + if not frappe.db.get_value( + "Custom Field", {"dt": doctype[0], "fieldname": self.source_fieldname} + ): + custom_fields.setdefault(doctype[0], dimension_field) + elif not frappe.db.get_value( + "Custom Field", {"dt": self.document_type, "fieldname": self.source_fieldname} + ): + custom_fields.setdefault(self.document_type, dimension_field) + + if not frappe.db.get_value( + "Custom Field", {"dt": "Stock Ledger Entry", "fieldname": self.target_fieldname} + ): + dimension_field["fieldname"] = self.target_fieldname + custom_fields["Stock Ledger Entry"] = dimension_field + + create_custom_fields(custom_fields) + + +@frappe.whitelist() +def get_inventory_documents( + doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None +): + and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No"]]] + or_filters = [ + ["DocField", "options", "in", ["Batch", "Serial No"]], + ["DocField", "parent", "in", ["Putaway Rule"]], + ] + + if txt: + and_filters.append(["DocField", "parent", "like", f"%{txt}%"]) + + return frappe.get_all( + "DocField", + fields=["distinct parent"], + filters=and_filters, + or_filters=or_filters, + start=start, + page_length=page_len, + as_list=1, + ) + + +def get_evaluated_inventory_dimension(doc, sl_dict, parent_doc=None): + dimensions = get_document_wise_inventory_dimensions(doc.doctype) + filter_dimensions = [] + for row in dimensions: + if ( + row.type_of_transaction == "Inward" + if doc.docstatus == 1 + else row.type_of_transaction != "Inward" + ) and sl_dict.actual_qty < 0: + continue + elif ( + row.type_of_transaction == "Outward" + if doc.docstatus == 1 + else row.type_of_transaction != "Inward" + ) and sl_dict.actual_qty > 0: + continue + + evals = {"doc": doc} + if parent_doc: + evals["parent"] = parent_doc + + if row.condition and frappe.safe_eval(row.condition, evals): + filter_dimensions.append(row) + else: + filter_dimensions.append(row) + + return filter_dimensions + + +def get_document_wise_inventory_dimensions(doctype) -> dict: + if not hasattr(frappe.local, "document_wise_inventory_dimensions"): + frappe.local.document_wise_inventory_dimensions = {} + + if not frappe.local.document_wise_inventory_dimensions.get(doctype): + dimensions = frappe.get_all( + "Inventory Dimension", + fields=["name", "source_fieldname", "condition", "target_fieldname", "type_of_transaction"], + filters={"disabled": 0}, + or_filters={"document_type": doctype, "apply_to_all_doctypes": 1}, + ) + + frappe.local.document_wise_inventory_dimensions[doctype] = dimensions + + return frappe.local.document_wise_inventory_dimensions[doctype] + + +@frappe.whitelist() +def get_inventory_dimensions(): + if not hasattr(frappe.local, "inventory_dimensions"): + frappe.local.inventory_dimensions = {} + + if not frappe.local.inventory_dimensions: + dimensions = frappe.get_all( + "Inventory Dimension", + fields=[ + "distinct target_fieldname as fieldname", + "reference_document as doctype", + ], + filters={"disabled": 0}, + ) + + frappe.local.inventory_dimensions = dimensions + + return frappe.local.inventory_dimensions diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py new file mode 100644 index 00000000000..998a0e9d6a6 --- /dev/null +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -0,0 +1,149 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( + CanNotBeChildDoc, + CanNotBeDefaultDimension, + DoNotChangeError, +) +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + +class TestInventoryDimension(FrappeTestCase): + def setUp(self): + prepare_test_data() + + def test_validate_inventory_dimension(self): + # Can not be child doc + inv_dim1 = create_inventory_dimension( + reference_document="Stock Entry Detail", + type_of_transaction="Outward", + dimension_name="Stock Entry", + apply_to_all_doctypes=0, + istable=0, + document_type="Stock Entry", + do_not_save=True, + ) + + self.assertRaises(CanNotBeChildDoc, inv_dim1.insert) + + inv_dim1 = create_inventory_dimension( + reference_document="Batch", + type_of_transaction="Outward", + dimension_name="Batch", + apply_to_all_doctypes=0, + document_type="Stock Entry Detail", + do_not_save=True, + ) + + self.assertRaises(CanNotBeDefaultDimension, inv_dim1.insert) + + def test_inventory_dimension(self): + warehouse = "Shelf Warehouse - _TC" + item_code = "_Test Item" + + inv_dim1 = create_inventory_dimension( + reference_document="Shelf", + type_of_transaction="Outward", + dimension_name="Shelf", + apply_to_all_doctypes=0, + document_type="Stock Entry Detail", + condition="parent.purpose == 'Material Issue'", + ) + + create_inventory_dimension( + reference_document="Shelf", + type_of_transaction="Inward", + dimension_name="To Shelf", + apply_to_all_doctypes=0, + document_type="Stock Entry Detail", + condition="parent.purpose == 'Material Receipt'", + ) + + inward = make_stock_entry( + item_code=item_code, + target=warehouse, + qty=5, + basic_rate=10, + do_not_save=True, + purpose="Material Receipt", + ) + + inward.items[0].to_shelf = "Shelf 1" + inward.save() + inward.submit() + inward.load_from_db() + + sle_data = frappe.db.get_value( + "Stock Ledger Entry", {"voucher_no": inward.name}, ["shelf", "warehouse"], as_dict=1 + ) + + self.assertEqual(inward.items[0].to_shelf, "Shelf 1") + self.assertEqual(sle_data.warehouse, warehouse) + self.assertEqual(sle_data.shelf, "Shelf 1") + + outward = make_stock_entry( + item_code=item_code, + source=warehouse, + qty=3, + basic_rate=10, + do_not_save=True, + purpose="Material Issue", + ) + + outward.items[0].shelf = "Shelf 1" + outward.save() + outward.submit() + outward.load_from_db() + + sle_shelf = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": outward.name}, "shelf") + self.assertEqual(sle_shelf, "Shelf 1") + + inv_dim1.load_from_db() + inv_dim1.apply_to_all_doctypes = 1 + + self.assertTrue(inv_dim1.has_stock_ledger()) + self.assertRaises(DoNotChangeError, inv_dim1.save) + + +def prepare_test_data(): + if not frappe.db.exists("DocType", "Shelf"): + frappe.get_doc( + { + "doctype": "DocType", + "name": "Shelf", + "module": "Stock", + "custom": 1, + "naming_rule": "By fieldname", + "autoname": "field:shelf_name", + "fields": [{"label": "Shelf Name", "fieldname": "shelf_name", "fieldtype": "Data"}], + "permissions": [ + {"role": "System Manager", "permlevel": 0, "read": 1, "write": 1, "create": 1, "delete": 1} + ], + } + ).insert(ignore_permissions=True) + + for shelf in ["Shelf 1", "Shelf 2"]: + if not frappe.db.exists("Shelf", shelf): + frappe.get_doc({"doctype": "Shelf", "shelf_name": shelf}).insert(ignore_permissions=True) + + create_warehouse("Shelf Warehouse") + + +def create_inventory_dimension(**args): + args = frappe._dict(args) + + if frappe.db.exists("Inventory Dimension", args.dimension_name): + return frappe.get_doc("Inventory Dimension", args.dimension_name) + + doc = frappe.new_doc("Inventory Dimension") + doc.update(args) + + if not args.do_not_save: + doc.insert(ignore_permissions=True) + + return doc diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9c494082893..f719c1efda1 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -478,10 +478,10 @@ class StockEntry(StockController): if not d.s_warehouse: frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) - if ( - cstr(d.s_warehouse) == cstr(d.t_warehouse) - and not self.purpose == "Material Transfer for Manufacture" - ): + if cstr(d.s_warehouse) == cstr(d.t_warehouse) and self.purpose not in [ + "Material Transfer for Manufacture", + "Material Transfer", + ]: frappe.throw(_("Source and target warehouse cannot be same for row {0}").format(d.idx)) if not (d.s_warehouse or d.t_warehouse): diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index ce6ffa0b914..9b3965d0d69 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -102,3 +102,5 @@ frappe.query_reports["Stock Balance"] = { return value; } }; + +erpnext.utils.add_inventory_dimensions('Stock Balance', 8); \ No newline at end of file diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 6369f910a41..679d234c9f6 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -13,6 +13,7 @@ from frappe.utils.nestedset import get_descendants_of from pypika.terms import ExistsCriterion import erpnext +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress @@ -66,9 +67,14 @@ def execute(filters: Optional[StockBalanceFilter] = None): _func = itemgetter(1) to_date = filters.get("to_date") - for (company, item, warehouse) in sorted(iwb_map): + + for group_by_key in iwb_map: + item = group_by_key[1] + warehouse = group_by_key[2] + company = group_by_key[0] + if item_map.get(item): - qty_dict = iwb_map[(company, item, warehouse)] + qty_dict = iwb_map[group_by_key] item_reorder_level = 0 item_reorder_qty = 0 if item + warehouse in item_reorder_detail_map: @@ -135,88 +141,104 @@ def get_columns(filters: StockBalanceFilter): "options": "Warehouse", "width": 100, }, - { - "label": _("Stock UOM"), - "fieldname": "stock_uom", - "fieldtype": "Link", - "options": "UOM", - "width": 90, - }, - { - "label": _("Balance Qty"), - "fieldname": "bal_qty", - "fieldtype": "Float", - "width": 100, - "convertible": "qty", - }, - { - "label": _("Balance Value"), - "fieldname": "bal_val", - "fieldtype": "Currency", - "width": 100, - "options": "currency", - }, - { - "label": _("Opening Qty"), - "fieldname": "opening_qty", - "fieldtype": "Float", - "width": 100, - "convertible": "qty", - }, - { - "label": _("Opening Value"), - "fieldname": "opening_val", - "fieldtype": "Currency", - "width": 110, - "options": "currency", - }, - { - "label": _("In Qty"), - "fieldname": "in_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - {"label": _("In Value"), "fieldname": "in_val", "fieldtype": "Float", "width": 80}, - { - "label": _("Out Qty"), - "fieldname": "out_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - {"label": _("Out Value"), "fieldname": "out_val", "fieldtype": "Float", "width": 80}, - { - "label": _("Valuation Rate"), - "fieldname": "val_rate", - "fieldtype": "Currency", - "width": 90, - "convertible": "rate", - "options": "currency", - }, - { - "label": _("Reorder Level"), - "fieldname": "reorder_level", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - { - "label": _("Reorder Qty"), - "fieldname": "reorder_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - { - "label": _("Company"), - "fieldname": "company", - "fieldtype": "Link", - "options": "Company", - "width": 100, - }, ] + for dimension in get_inventory_dimensions(): + columns.append( + { + "label": _(dimension.doctype), + "fieldname": dimension.fieldname, + "fieldtype": "Link", + "options": dimension.doctype, + "width": 110, + } + ) + + columns.extend( + [ + { + "label": _("Stock UOM"), + "fieldname": "stock_uom", + "fieldtype": "Link", + "options": "UOM", + "width": 90, + }, + { + "label": _("Balance Qty"), + "fieldname": "bal_qty", + "fieldtype": "Float", + "width": 100, + "convertible": "qty", + }, + { + "label": _("Balance Value"), + "fieldname": "bal_val", + "fieldtype": "Currency", + "width": 100, + "options": "currency", + }, + { + "label": _("Opening Qty"), + "fieldname": "opening_qty", + "fieldtype": "Float", + "width": 100, + "convertible": "qty", + }, + { + "label": _("Opening Value"), + "fieldname": "opening_val", + "fieldtype": "Currency", + "width": 110, + "options": "currency", + }, + { + "label": _("In Qty"), + "fieldname": "in_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + {"label": _("In Value"), "fieldname": "in_val", "fieldtype": "Float", "width": 80}, + { + "label": _("Out Qty"), + "fieldname": "out_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + {"label": _("Out Value"), "fieldname": "out_val", "fieldtype": "Float", "width": 80}, + { + "label": _("Valuation Rate"), + "fieldname": "val_rate", + "fieldtype": "Currency", + "width": 90, + "convertible": "rate", + "options": "currency", + }, + { + "label": _("Reorder Level"), + "fieldname": "reorder_level", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + { + "label": _("Reorder Qty"), + "fieldname": "reorder_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + { + "label": _("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "width": 100, + }, + ] + ) + if filters.get("show_stock_ageing_data"): columns += [ {"label": _("Average Age"), "fieldname": "average_age", "width": 100}, @@ -296,6 +318,13 @@ def get_stock_ledger_entries(filters: StockBalanceFilter, items: List[str]) -> L .orderby(sle.actual_qty) ) + inventory_dimension_fields = get_inventory_dimension_fields() + if inventory_dimension_fields: + for fieldname in inventory_dimension_fields: + query = query.select(fieldname) + if fieldname in filters and filters.get(fieldname): + query = query.where(sle[fieldname].isin(filters.get(fieldname))) + if items: query = query.where(sle.item_code.isin(items)) @@ -303,6 +332,10 @@ def get_stock_ledger_entries(filters: StockBalanceFilter, items: List[str]) -> L return query.run(as_dict=True) +def get_inventory_dimension_fields(): + return [dimension.fieldname for dimension in get_inventory_dimensions()] + + def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): iwb_map = {} from_date = getdate(filters.get("from_date")) @@ -310,10 +343,12 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): float_precision = cint(frappe.db.get_default("float_precision")) or 3 + inventory_dimensions = get_inventory_dimension_fields() + for d in sle: - key = (d.company, d.item_code, d.warehouse) - if key not in iwb_map: - iwb_map[key] = frappe._dict( + group_by_key = get_group_by_key(d, filters, inventory_dimensions) + if group_by_key not in iwb_map: + iwb_map[group_by_key] = frappe._dict( { "opening_qty": 0.0, "opening_val": 0.0, @@ -327,7 +362,9 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): } ) - qty_dict = iwb_map[(d.company, d.item_code, d.warehouse)] + qty_dict = iwb_map[group_by_key] + for field in inventory_dimensions: + qty_dict[field] = d.get(field) if d.voucher_type == "Stock Reconciliation" and not d.batch_no: qty_diff = flt(d.qty_after_transaction) - flt(qty_dict.bal_qty) @@ -356,24 +393,41 @@ def get_item_warehouse_map(filters: StockBalanceFilter, sle: List[SLEntry]): qty_dict.bal_qty += qty_diff qty_dict.bal_val += value_diff - iwb_map = filter_items_with_no_transactions(iwb_map, float_precision) + iwb_map = filter_items_with_no_transactions(iwb_map, float_precision, inventory_dimensions) return iwb_map -def filter_items_with_no_transactions(iwb_map, float_precision: float): - for (company, item, warehouse) in sorted(iwb_map): - qty_dict = iwb_map[(company, item, warehouse)] +def get_group_by_key(row, filters, inventory_dimension_fields) -> tuple: + group_by_key = [row.company, row.item_code, row.warehouse] + + for fieldname in inventory_dimension_fields: + if filters.get(fieldname): + group_by_key.append(row.get(fieldname)) + + return tuple(group_by_key) + + +def filter_items_with_no_transactions(iwb_map, float_precision: float, inventory_dimensions: list): + pop_keys = [] + for group_by_key in iwb_map: + qty_dict = iwb_map[group_by_key] no_transactions = True for key, val in qty_dict.items(): + if key in inventory_dimensions: + continue + val = flt(val, float_precision) qty_dict[key] = val if key != "val_rate" and val: no_transactions = False if no_transactions: - iwb_map.pop((company, item, warehouse)) + pop_keys.append(group_by_key) + + for key in pop_keys: + iwb_map.pop(key) return iwb_map diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index ef7c2cc7d9e..0def161d283 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -95,4 +95,6 @@ frappe.query_reports["Stock Ledger"] = { return value; }, -} +}; + +erpnext.utils.add_inventory_dimensions('Stock Ledger', 10); \ No newline at end of file diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index ef1642e1f98..e18d4c75229 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -4,8 +4,11 @@ import frappe from frappe import _ +from frappe.query_builder.functions import CombineDatetime from frappe.utils import cint, flt +from pypika.terms import ExistsCriterion +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import get_stock_balance_for from erpnext.stock.utils import ( @@ -17,7 +20,7 @@ from erpnext.stock.utils import ( def execute(filters=None): is_reposting_item_valuation_in_progress() include_uom = filters.get("include_uom") - columns = get_columns() + columns = get_columns(filters) items = get_items(filters) sl_entries = get_stock_ledger_entries(filters, items) item_details = get_item_details(items, sl_entries, include_uom) @@ -33,12 +36,14 @@ def execute(filters=None): actual_qty = stock_value = 0 available_serial_nos = {} + inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters) + for sle in sl_entries: item_detail = item_details[sle.item_code] sle.update(item_detail) - if filters.get("batch_no"): + if filters.get("batch_no") or inventory_dimension_filters_applied: actual_qty += flt(sle.actual_qty, precision) stock_value += sle.stock_value_difference @@ -67,7 +72,7 @@ def update_available_serial_nos(available_serial_nos, sle): key = (sle.item_code, sle.warehouse) if key not in available_serial_nos: stock_balance = get_stock_balance_for( - sle.item_code, sle.warehouse, sle.date.split(" ")[0], sle.date.split(" ")[1] + sle.item_code, sle.warehouse, sle.posting_date, sle.posting_time ) serials = get_serial_nos(stock_balance["serial_nos"]) if stock_balance["serial_nos"] else [] available_serial_nos.setdefault(key, serials) @@ -88,7 +93,7 @@ def update_available_serial_nos(available_serial_nos, sle): sle.balance_serial_no = "\n".join(existing_serial_no) -def get_columns(): +def get_columns(filters): columns = [ {"label": _("Date"), "fieldname": "date", "fieldtype": "Datetime", "width": 150}, { @@ -106,170 +111,210 @@ def get_columns(): "options": "UOM", "width": 90, }, - { - "label": _("In Qty"), - "fieldname": "in_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - { - "label": _("Out Qty"), - "fieldname": "out_qty", - "fieldtype": "Float", - "width": 80, - "convertible": "qty", - }, - { - "label": _("Balance Qty"), - "fieldname": "qty_after_transaction", - "fieldtype": "Float", - "width": 100, - "convertible": "qty", - }, - { - "label": _("Voucher #"), - "fieldname": "voucher_no", - "fieldtype": "Dynamic Link", - "options": "voucher_type", - "width": 150, - }, - { - "label": _("Warehouse"), - "fieldname": "warehouse", - "fieldtype": "Link", - "options": "Warehouse", - "width": 150, - }, - { - "label": _("Item Group"), - "fieldname": "item_group", - "fieldtype": "Link", - "options": "Item Group", - "width": 100, - }, - { - "label": _("Brand"), - "fieldname": "brand", - "fieldtype": "Link", - "options": "Brand", - "width": 100, - }, - {"label": _("Description"), "fieldname": "description", "width": 200}, - { - "label": _("Incoming Rate"), - "fieldname": "incoming_rate", - "fieldtype": "Currency", - "width": 110, - "options": "Company:company:default_currency", - "convertible": "rate", - }, - { - "label": _("Valuation Rate"), - "fieldname": "valuation_rate", - "fieldtype": "Currency", - "width": 110, - "options": "Company:company:default_currency", - "convertible": "rate", - }, - { - "label": _("Balance Value"), - "fieldname": "stock_value", - "fieldtype": "Currency", - "width": 110, - "options": "Company:company:default_currency", - }, - { - "label": _("Value Change"), - "fieldname": "stock_value_difference", - "fieldtype": "Currency", - "width": 110, - "options": "Company:company:default_currency", - }, - {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, - { - "label": _("Voucher #"), - "fieldname": "voucher_no", - "fieldtype": "Dynamic Link", - "options": "voucher_type", - "width": 100, - }, - { - "label": _("Batch"), - "fieldname": "batch_no", - "fieldtype": "Link", - "options": "Batch", - "width": 100, - }, - { - "label": _("Serial No"), - "fieldname": "serial_no", - "fieldtype": "Link", - "options": "Serial No", - "width": 100, - }, - {"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100}, - { - "label": _("Project"), - "fieldname": "project", - "fieldtype": "Link", - "options": "Project", - "width": 100, - }, - { - "label": _("Company"), - "fieldname": "company", - "fieldtype": "Link", - "options": "Company", - "width": 110, - }, ] + for dimension in get_inventory_dimensions(): + columns.append( + { + "label": _(dimension.doctype), + "fieldname": dimension.fieldname, + "fieldtype": "Link", + "options": dimension.doctype, + "width": 110, + } + ) + + columns.extend( + [ + { + "label": _("In Qty"), + "fieldname": "in_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + { + "label": _("Out Qty"), + "fieldname": "out_qty", + "fieldtype": "Float", + "width": 80, + "convertible": "qty", + }, + { + "label": _("Balance Qty"), + "fieldname": "qty_after_transaction", + "fieldtype": "Float", + "width": 100, + "convertible": "qty", + }, + { + "label": _("Voucher #"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": 150, + }, + { + "label": _("Warehouse"), + "fieldname": "warehouse", + "fieldtype": "Link", + "options": "Warehouse", + "width": 150, + }, + { + "label": _("Item Group"), + "fieldname": "item_group", + "fieldtype": "Link", + "options": "Item Group", + "width": 100, + }, + { + "label": _("Brand"), + "fieldname": "brand", + "fieldtype": "Link", + "options": "Brand", + "width": 100, + }, + {"label": _("Description"), "fieldname": "description", "width": 200}, + { + "label": _("Incoming Rate"), + "fieldname": "incoming_rate", + "fieldtype": "Currency", + "width": 110, + "options": "Company:company:default_currency", + "convertible": "rate", + }, + { + "label": _("Valuation Rate"), + "fieldname": "valuation_rate", + "fieldtype": "Currency", + "width": 110, + "options": "Company:company:default_currency", + "convertible": "rate", + }, + { + "label": _("Balance Value"), + "fieldname": "stock_value", + "fieldtype": "Currency", + "width": 110, + "options": "Company:company:default_currency", + }, + { + "label": _("Value Change"), + "fieldname": "stock_value_difference", + "fieldtype": "Currency", + "width": 110, + "options": "Company:company:default_currency", + }, + {"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110}, + { + "label": _("Voucher #"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": 100, + }, + { + "label": _("Batch"), + "fieldname": "batch_no", + "fieldtype": "Link", + "options": "Batch", + "width": 100, + }, + { + "label": _("Serial No"), + "fieldname": "serial_no", + "fieldtype": "Link", + "options": "Serial No", + "width": 100, + }, + {"label": _("Balance Serial No"), "fieldname": "balance_serial_no", "width": 100}, + { + "label": _("Project"), + "fieldname": "project", + "fieldtype": "Link", + "options": "Project", + "width": 100, + }, + { + "label": _("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "width": 110, + }, + ] + ) + return columns def get_stock_ledger_entries(filters, items): - item_conditions_sql = "" - if items: - item_conditions_sql = "and sle.item_code in ({})".format( - ", ".join(frappe.db.escape(i) for i in items) + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select( + sle.item_code, + CombineDatetime(sle.posting_date, sle.posting_time).as_("date"), + sle.warehouse, + sle.posting_date, + sle.posting_time, + sle.actual_qty, + sle.incoming_rate, + sle.valuation_rate, + sle.company, + sle.voucher_type, + sle.qty_after_transaction, + sle.stock_value_difference, + sle.voucher_no, + sle.stock_value, + sle.batch_no, + sle.serial_no, + sle.project, ) - - sl_entries = frappe.db.sql( - """ - SELECT - concat_ws(' ', posting_date, posting_time) AS date, - item_code, - warehouse, - actual_qty, - qty_after_transaction, - incoming_rate, - valuation_rate, - stock_value, - voucher_type, - voucher_no, - batch_no, - serial_no, - company, - project, - stock_value_difference - FROM - `tabStock Ledger Entry` sle - WHERE - company = %(company)s - AND is_cancelled = 0 AND posting_date BETWEEN %(from_date)s AND %(to_date)s - {sle_conditions} - {item_conditions_sql} - ORDER BY - posting_date asc, posting_time asc, creation asc - """.format( - sle_conditions=get_sle_conditions(filters), item_conditions_sql=item_conditions_sql - ), - filters, - as_dict=1, + .where( + (sle.docstatus < 2) + & (sle.is_cancelled == 0) + & (sle.posting_date[filters.from_date : filters.to_date]) + ) + .orderby(CombineDatetime(sle.posting_date, sle.posting_time)) + .orderby(sle.creation) ) - return sl_entries + inventory_dimension_fields = get_inventory_dimension_fields() + if inventory_dimension_fields: + for fieldname in inventory_dimension_fields: + query = query.select(fieldname) + if fieldname in filters and filters.get(fieldname): + query = query.where(sle[fieldname].isin(filters.get(fieldname))) + + if items: + query = query.where(sle.item_code.isin(items)) + + for field in ["voucher_no", "batch_no", "project", "company"]: + if filters.get(field): + query = query.where(sle[field] == filters.get(field)) + + if warehouse := filters.get("warehouse"): + lft, rgt = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"]) + + warehouse_table = frappe.qb.DocType("Warehouse") + chilren_subquery = ( + frappe.qb.from_(warehouse_table) + .select(warehouse_table.name) + .where( + (warehouse_table.lft >= lft) + & (warehouse_table.rgt <= rgt) + & (warehouse_table.name == sle.warehouse) + ) + ) + query = query.where(ExistsCriterion(chilren_subquery)) + + return query.run(as_dict=True) + + +def get_inventory_dimension_fields(): + return [dimension.fieldname for dimension in get_inventory_dimensions()] def get_items(filters): @@ -341,6 +386,10 @@ def get_sle_conditions(filters): if filters.get("project"): conditions.append("project=%(project)s") + for dimension in get_inventory_dimensions(): + if filters.get(dimension.fieldname): + conditions.append(f"{dimension.fieldname} in %({dimension.fieldname})s") + return "and {}".format(" and ".join(conditions)) if conditions else "" @@ -363,7 +412,7 @@ def get_opening_balance(filters, columns, sl_entries): for sle in sl_entries: if ( sle.get("voucher_type") == "Stock Reconciliation" - and sle.get("date").split()[0] == filters.from_date + and sle.posting_date == filters.from_date and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock" ): last_entry = sle @@ -401,3 +450,11 @@ def get_item_group_condition(item_group): ) return "" + + +def check_inventory_dimension_filters_applied(filters) -> bool: + for dimension in get_inventory_dimensions(): + if dimension.fieldname in filters and filters.get(dimension.fieldname): + return True + + return False