diff --git a/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json b/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json index bd59f65dd4c..1ab1680d108 100644 --- a/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json +++ b/erpnext/accounts/doctype/sales_invoice_payment/sales_invoice_payment.json @@ -26,7 +26,7 @@ }, { "default": "0", - "depends_on": "eval:parent.doctype == 'Sales Invoice'", + "depends_on": "eval: [\"POS Invoice\", \"Sales Invoice\"].includes(parent.doctype)", "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, @@ -85,7 +85,7 @@ ], "istable": 1, "links": [], - "modified": "2024-01-23 16:20:06.436979", + "modified": "2026-02-16 20:46:34.592604", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Payment", @@ -95,4 +95,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 60b3efd3cbc..ab86dcfd15c 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -804,12 +804,19 @@ def validate_against_pcv(is_opening, posting_date, company): title=_("Invalid Opening Entry"), ) - last_pcv_date = frappe.db.get_value( - "Period Closing Voucher", {"docstatus": 1, "company": company}, "max(period_end_date)" - ) + # Local import so you don't have to touch file-level imports + from frappe.query_builder.functions import Max + + pcv = frappe.qb.DocType("Period Closing Voucher") + + last_pcv_date = ( + frappe.qb.from_(pcv) + .select(Max(pcv.period_end_date)) + .where((pcv.docstatus == 1) & (pcv.company == company)) + ).run(pluck=True)[0] if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date): - message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date)) + message = _("Books have been closed till the period ending on {0}.").format(formatdate(last_pcv_date)) message += "
" message += _("You cannot create/amend any accounting entries till this date.") frappe.throw(message, title=_("Period Closed")) diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py index 5f3215fe7e2..4d057636a69 100644 --- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py +++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py @@ -3,7 +3,8 @@ import frappe -from frappe import _ +from frappe import _, qb +from frappe.query_builder import Criterion from frappe.utils import cstr, flt from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions @@ -33,11 +34,19 @@ def execute(filters=None): def get_accounts_data(based_on, company): if based_on == "Cost Center": - return frappe.db.sql( - """select name, parent_cost_center as parent_account, cost_center_name as account_name, lft, rgt - from `tabCost Center` where company=%s order by name""", - company, - as_dict=True, + cc = qb.DocType("Cost Center") + return ( + qb.from_(cc) + .select( + cc.name, + cc.parent_cost_center.as_("parent_account"), + cc.cost_center_name.as_("account_name"), + cc.lft, + cc.rgt, + ) + .where(cc.company.eq(company)) + .orderby(cc.name) + .run(as_dict=True) ) elif based_on == "Project": return frappe.get_all("Project", fields=["name"], filters={"company": company}, order_by="name") @@ -206,27 +215,38 @@ def set_gl_entries_by_account( company, from_date, to_date, based_on, gl_entries_by_account, ignore_closing_entries=False ): """Returns a dict like { "account": [gl entries], ... }""" - additional_conditions = [] + gl = qb.DocType("GL Entry") + acc = qb.DocType("Account") + + conditions = [] + conditions.append(gl.company.eq(company)) + conditions.append(gl[based_on].notnull()) + conditions.append(gl.is_cancelled.eq(0)) + + if from_date and to_date: + conditions.append(gl.posting_date.between(from_date, to_date)) + elif from_date and not to_date: + conditions.append(gl.posting_date.gte(from_date)) + elif not from_date and to_date: + conditions.append(gl.posting_date.lte(to_date)) if ignore_closing_entries: - additional_conditions.append("and voucher_type !='Period Closing Voucher'") + conditions.append(gl.voucher_type.ne("Period Closing Voucher")) - if from_date: - additional_conditions.append("and posting_date >= %(from_date)s") - - gl_entries = frappe.db.sql( - """select posting_date, {based_on} as based_on, debit, credit, - is_opening, (select root_type from `tabAccount` where name = account) as type - from `tabGL Entry` where company=%(company)s - {additional_conditions} - and posting_date <= %(to_date)s - and {based_on} is not null - and is_cancelled = 0 - order by {based_on}, posting_date""".format( - additional_conditions="\n".join(additional_conditions), based_on=based_on - ), - {"company": company, "from_date": from_date, "to_date": to_date}, - as_dict=True, + root_subquery = qb.from_(acc).select(acc.root_type).where(acc.name.eq(gl.account)) + gl_entries = ( + qb.from_(gl) + .select( + gl.posting_date, + gl[based_on].as_("based_on"), + gl.debit, + gl.credit, + gl.is_opening, + root_subquery.as_("type"), + ) + .where(Criterion.all(conditions)) + .orderby(gl[based_on], gl.posting_date) + .run(as_dict=True) ) for entry in gl_entries: diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 78fc08614f2..9fb40938d59 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -51,7 +51,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ entries = {} for name, details in gle_map.items(): for entry in details: - tax_amount, total_amount, grand_total, base_total = 0, 0, 0, 0 + tax_amount, total_amount, grand_total, base_total, base_tax_withholding_net_total = 0, 0, 0, 0, 0 tax_withholding_category, rate = None, None bill_no, bill_date = "", "" party = entry.party or entry.against @@ -83,6 +83,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ # back calculate total amount from rate and tax_amount base_total = min(flt(tax_amount / (rate / 100), precision=precision), values[0]) total_amount = grand_total = base_total + base_tax_withholding_net_total = total_amount else: if tax_amount and rate: @@ -93,12 +94,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ grand_total = values[1] base_total = values[2] + base_tax_withholding_net_total = total_amount if voucher_type == "Purchase Invoice": + base_tax_withholding_net_total = values[0] bill_no = values[3] bill_date = values[4] + else: total_amount += entry.credit + base_tax_withholding_net_total = total_amount if tax_amount: if party_map.get(party, {}).get("party_type") == "Supplier": @@ -125,6 +130,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map, journal_entry_ "rate": rate, "total_amount": total_amount, "grand_total": grand_total, + "base_tax_withholding_net_total": base_tax_withholding_net_total, "base_total": base_total, "tax_amount": tax_amount, "transaction_date": posting_date, @@ -252,14 +258,14 @@ def get_columns(filters): "width": 60, }, { - "label": _("Total Amount"), - "fieldname": "total_amount", + "label": _("Tax Withholding Net Total"), + "fieldname": "base_tax_withholding_net_total", "fieldtype": "Float", - "width": 120, + "width": 150, }, { - "label": _("Base Total"), - "fieldname": "base_total", + "label": _("Taxable Amount"), + "fieldname": "total_amount", "fieldtype": "Float", "width": 120, }, @@ -270,10 +276,16 @@ def get_columns(filters): "width": 120, }, { - "label": _("Grand Total"), + "label": _("Grand Total (Company Currency)"), + "fieldname": "base_total", + "fieldtype": "Float", + "width": 150, + }, + { + "label": _("Grand Total (Transaction Currency)"), "fieldname": "grand_total", "fieldtype": "Float", - "width": 120, + "width": 170, }, {"label": _("Transaction Type"), "fieldname": "transaction_type", "width": 130}, { diff --git a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py index 853ae71abe3..56dba9d86d3 100644 --- a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py @@ -35,9 +35,9 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): result = execute(filters)[1] expected_values = [ # Check for JV totals using back calculation logic - [jv.name, "TCS", 0.075, -10000.0, -7.5, -10000.0], - [pe.name, "TCS", 0.075, 2550, 0.53, 2550.53], - [si.name, "TCS", 0.075, 1000, 0.52, 1000.52], + [jv.name, "TCS", 0.075, -10000.0, -10000.0, -7.5, -10000.0], + [pe.name, "TCS", 0.075, 706.67, 2550.0, 0.53, 2550.53], + [si.name, "TCS", 0.075, 693.33, 1000.0, 0.52, 1000.52], ] self.check_expected_values(result, expected_values) @@ -55,8 +55,8 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): frappe._dict(company="_Test Company", party_type="Supplier", from_date=today(), to_date=today()) )[1] expected_values = [ - [inv_1.name, "TDS - 1", 10, 5000, 500, 5500], - [inv_2.name, "TDS - 2", 20, 5000, 1000, 6000], + [inv_1.name, "TDS - 1", 10, 5000, 5000, 500, 5500], + [inv_2.name, "TDS - 2", 20, 5000, 5000, 1000, 6000], ] self.check_expected_values(result, expected_values) @@ -107,8 +107,8 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): )[1] expected_values = [ - [inv_1.name, "TDS - 3", 10.0, 5000, 500, 4500], - [inv_2.name, "TDS - 3", 20.0, 5000, 1000, 4000], + [inv_1.name, "TDS - 3", 10.0, 5000, 5000, 500, 4500], + [inv_2.name, "TDS - 3", 20.0, 5000, 5000, 1000, 4000], ] self.check_expected_values(result, expected_values) @@ -120,6 +120,7 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): voucher.ref_no, voucher.section_code, voucher.rate, + voucher.base_tax_withholding_net_total, voucher.base_total, voucher.tax_amount, voucher.grand_total, diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index e14d9320fa2..1b5292f6bde 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -128,7 +128,7 @@ def get_columns(filters): "width": 120, }, { - "label": _("Total Amount"), + "label": _("Total Taxable Amount"), "fieldname": "total_amount", "fieldtype": "Float", "width": 120, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index f6195e6e09e..66a3dd93169 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2580,12 +2580,12 @@ class AccountsController(TransactionBase): def get_order_details(self): if self.doctype == "Sales Invoice": - po_or_so = self.get("items")[0].get("sales_order") + po_or_so = self.get("items") and self.get("items")[0].get("sales_order") po_or_so_doctype = "Sales Order" po_or_so_doctype_name = "sales_order" else: - po_or_so = self.get("items")[0].get("purchase_order") + po_or_so = self.get("items") and self.get("items")[0].get("purchase_order") po_or_so_doctype = "Purchase Order" po_or_so_doctype_name = "purchase_order" @@ -4002,6 +4002,12 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil flt(d.get("conversion_factor"), conv_fac_precision) or conversion_factor ) + if child_item.get("total_weight") and child_item.get("weight_per_unit"): + child_item.total_weight = flt( + child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, + child_item.precision("total_weight"), + ) + if d.get("delivery_date") and parent_doctype == "Sales Order": child_item.delivery_date = d.get("delivery_date") @@ -4054,6 +4060,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.set_payment_schedule() if parent_doctype == "Purchase Order": + parent.set_tax_withholding() parent.validate_minimum_order_qty() parent.validate_budget() if parent.is_against_so(): diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 8c8692d0be3..06a8f25b186 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -626,7 +626,9 @@ class BuyingController(SubcontractingController): or self.is_return or (self.is_internal_transfer() and self.docstatus == 2) else self.get_package_for_target_warehouse( - d, type_of_transaction=type_of_transaction + d, + type_of_transaction=type_of_transaction, + via_landed_cost_voucher=via_landed_cost_voucher, ) ), }, @@ -714,7 +716,22 @@ class BuyingController(SubcontractingController): via_landed_cost_voucher=via_landed_cost_voucher, ) - def get_package_for_target_warehouse(self, item, warehouse=None, type_of_transaction=None) -> str: + def get_package_for_target_warehouse( + self, item, warehouse=None, type_of_transaction=None, via_landed_cost_voucher=None + ) -> str: + if via_landed_cost_voucher and item.get("warehouse"): + if sabb := frappe.db.get_value( + "Serial and Batch Bundle", + { + "voucher_detail_no": item.name, + "warehouse": item.get("warehouse"), + "docstatus": 1, + "is_cancelled": 0, + }, + "name", + ): + return sabb + if not item.serial_and_batch_bundle: return "" diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index a20d59333b6..3860a7106c4 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1591,7 +1591,7 @@ def get_gl_entries_for_preview(doctype, docname, fields): def get_columns(raw_columns, fields): return [ - {"name": d.get("label"), "editable": False, "width": 110} + {"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")} for d in raw_columns if not d.get("hidden") and d.get("fieldname") in fields ] diff --git a/erpnext/crm/doctype/market_segment/market_segment.json b/erpnext/crm/doctype/market_segment/market_segment.json index 66cca0ed400..85200018848 100644 --- a/erpnext/crm/doctype/market_segment/market_segment.json +++ b/erpnext/crm/doctype/market_segment/market_segment.json @@ -1,96 +1,48 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:market_segment", - "beta": 0, - "creation": "2018-10-01 09:59:14.479509", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "allow_rename": 1, + "autoname": "field:market_segment", + "creation": "2018-10-01 09:59:14.479509", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "market_segment" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "market_segment", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Market Segment", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, + "fieldname": "market_segment", + "fieldtype": "Data", + "label": "Market Segment", "unique": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-10-01 09:59:14.479509", - "modified_by": "Administrator", - "module": "CRM", - "name": "Market Segment", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2025-12-17 12:09:34.687368", + "modified_by": "Administrator", + "module": "CRM", + "name": "Market Segment", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Sales Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 -} \ No newline at end of file + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "translated_doctype": 1 +} diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 3bcc4e896bd..a40d0d714ed 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -133,7 +133,7 @@ "label": "Batch Size" }, { - "depends_on": "eval:doc.parenttype == \"Routing\"", + "depends_on": "eval:doc.parenttype == \"Routing\" || !parent.routing", "fieldname": "sequence_id", "fieldtype": "Int", "label": "Sequence ID" @@ -196,7 +196,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-07-31 16:17:47.287117", + "modified": "2026-02-17 15:33:28.495850", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/plant_floor/plant_floor.json b/erpnext/manufacturing/doctype/plant_floor/plant_floor.json index c1c167c395b..556a0c33721 100644 --- a/erpnext/manufacturing/doctype/plant_floor/plant_floor.json +++ b/erpnext/manufacturing/doctype/plant_floor/plant_floor.json @@ -49,7 +49,8 @@ "fieldname": "warehouse", "fieldtype": "Link", "label": "Warehouse", - "options": "Warehouse" + "options": "Warehouse", + "set_only_once": 1 }, { "depends_on": "eval:!doc.__islocal && doc.warehouse", @@ -66,13 +67,14 @@ "fieldname": "company", "fieldtype": "Link", "label": "Company", - "options": "Company" + "options": "Company", + "set_only_once": 1 } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2024-09-19 19:06:36.481625", + "modified": "2026-02-17 11:53:17.940039", "modified_by": "Administrator", "module": "Manufacturing", "name": "Plant Floor", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index d38da9257ff..351ba27a43e 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -648,8 +648,8 @@ class ProductionPlan(Document): self.status = "Completed" if self.status != "Completed": - self.update_ordered_status() self.update_requested_status() + self.update_ordered_status() if close is not None: self.db_set("status", self.status) @@ -658,25 +658,17 @@ class ProductionPlan(Document): self.update_bin_qty() def update_ordered_status(self): - update_status = False - for d in self.po_items: - if d.planned_qty == d.ordered_qty: - update_status = True - - if update_status and self.status != "Completed": - self.status = "In Process" + for child_table in ["po_items", "sub_assembly_items"]: + for item in self.get(child_table): + if item.ordered_qty: + self.status = "In Process" + return def update_requested_status(self): - if not self.mr_items: - return - - update_status = True for d in self.mr_items: - if d.quantity != d.requested_qty: - update_status = False - - if update_status: - self.status = "Material Requested" + if d.requested_qty: + self.status = "Material Requested" + break def get_production_items(self): item_dict = {} @@ -798,6 +790,8 @@ class ProductionPlan(Document): "stock_uom", "bom_level", "schedule_date", + "sales_order", + "sales_order_item", ]: if row.get(field): wo_data[field] = row.get(field) @@ -837,6 +831,8 @@ class ProductionPlan(Document): "qty", "description", "production_plan_item", + "sales_order", + "sales_order_item", ]: po_data[field] = row.get(field) @@ -1023,6 +1019,10 @@ class ProductionPlan(Document): if not is_group_warehouse: data.fg_warehouse = self.sub_assembly_warehouse + if not self.combine_sub_items: + data.sales_order = row.sales_order + data.sales_order_item = row.sales_order_item + def set_default_supplier_for_subcontracting_order(self): items = [ d.production_item for d in self.sub_assembly_items if d.type_of_manufacturing == "Subcontract" diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 25fc4f26247..62aa4f6ea11 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -565,6 +565,90 @@ class TestProductionPlan(FrappeTestCase): self.assertEqual(po_doc.items[0].fg_item, fg_item) self.assertEqual(po_doc.items[0].item_code, service_item) + def test_sales_order_references_for_sub_assembly_items(self): + """ + Test that Sales Order and Sales Order Item references in Work Order and Purchase Order + are correctly propagated from the Production Plan. + """ + + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + # Setup Test Items & BOM + fg_item = "Test FG Good Item" + sub_assembly_item1 = "Test Sub Assembly Item 1" + sub_assembly_item2 = "Test Sub Assembly Item 2" + + bom_tree = { + fg_item: { + sub_assembly_item1: {"Test Raw Material 1": {}}, + sub_assembly_item2: {"Test Raw Material 2": {}}, + } + } + + create_nested_bom(bom_tree, prefix="") + + # Create Sales Order + so = make_sales_order(item_code=fg_item, qty=10) + so_item_row = so.items[0].name + + # Create Production Plan from Sales Order + production_plan = frappe.new_doc("Production Plan") + production_plan.company = so.company + production_plan.get_items_from = "Sales Order" + production_plan.item_code = fg_item + + production_plan.get_open_sales_orders() + self.assertEqual(production_plan.sales_orders[0].sales_order, so.name) + + production_plan.get_so_items() + + production_plan.skip_available_sub_assembly_item = 0 + production_plan.get_sub_assembly_items() + + self.assertEqual(len(production_plan.sub_assembly_items), 2) + + # Validate Sales Order references in Sub Assembly Items + for row in production_plan.sub_assembly_items: + if row.production_item == sub_assembly_item1: + row.supplier = "_Test Supplier" + row.type_of_manufacturing = "Subcontract" + + self.assertEqual(row.sales_order, so.name) + self.assertEqual(row.sales_order_item, so_item_row) + + # Submit Production Plan + production_plan.save() + production_plan.submit() + production_plan.make_work_order() + + # Validate Purchase Order (Subcontracted Item) + po_items = frappe.get_all( + "Purchase Order Item", + { + "production_plan": production_plan.name, + "fg_item": sub_assembly_item1, + }, + ["sales_order", "sales_order_item"], + ) + + self.assertTrue(po_items) + self.assertEqual(po_items[0].sales_order, so.name) + self.assertEqual(po_items[0].sales_order_item, so_item_row) + + # Validate Work Order (In-house Item) + work_orders = frappe.get_all( + "Work Order", + { + "production_plan": production_plan.name, + "production_item": sub_assembly_item2, + }, + ["sales_order", "sales_order_item"], + ) + + self.assertTrue(work_orders) + self.assertEqual(work_orders[0].sales_order, so.name) + self.assertEqual(work_orders[0].sales_order_item, so_item_row) + def test_production_plan_combine_subassembly(self): """ Test combining Sub assembly items belonging to the same BOM in Prod Plan. diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json index b5f6a3ab065..9818beaa2c8 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -10,6 +10,8 @@ "fg_warehouse", "parent_item_code", "schedule_date", + "sales_order", + "sales_order_item", "column_break_3", "qty", "bom_no", @@ -212,20 +214,36 @@ "label": "Ordered Qty", "no_copy": 1, "read_only": 1 + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "options": "Sales Order", + "read_only": 1 + }, + { + "fieldname": "sales_order_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Sales Order Item", + "no_copy": 1, + "print_hide": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-06-10 13:36:24.759101", + "modified": "2026-02-17 12:06:02.309032", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py index 7e29675136c..42031d437b8 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py @@ -33,6 +33,8 @@ class ProductionPlanSubAssemblyItem(Document): purchase_order: DF.Link | None qty: DF.Float received_qty: DF.Float + sales_order: DF.Link | None + sales_order_item: DF.Data | None schedule_date: DF.Datetime | None stock_uom: DF.Link | None supplier: DF.Link | None diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index de0a7ab51bc..30c4807743d 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -598,9 +598,20 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { ? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff : this.frm.doc.net_total); - if(["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(this.frm.doc.doctype)) { - this.frm.doc.base_grand_total = (this.frm.doc.total_taxes_and_charges) ? - flt(this.frm.doc.grand_total * this.frm.doc.conversion_rate) : this.frm.doc.base_net_total; + // total taxes and charges is calculated before adjusting base grand total + this.frm.doc.total_taxes_and_charges = flt( + this.frm.doc.grand_total - this.frm.doc.net_total - grand_total_diff, + precision("total_taxes_and_charges") + ); + + if ( + ["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes( + this.frm.doc.doctype + ) + ) { + this.frm.doc.base_grand_total = this.frm.doc.total_taxes_and_charges + ? flt(this.frm.doc.grand_total * this.frm.doc.conversion_rate) + : this.frm.doc.base_net_total; } else { // other charges added/deducted this.frm.doc.taxes_and_charges_added = this.frm.doc.taxes_and_charges_deducted = 0.0; @@ -626,11 +637,6 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { ["taxes_and_charges_added", "taxes_and_charges_deducted"]); } - this.frm.doc.total_taxes_and_charges = flt(this.frm.doc.grand_total - this.frm.doc.net_total - - grand_total_diff, precision("total_taxes_and_charges")); - - this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges"]); - // Round grand total as per precision frappe.model.round_floats_in(this.frm.doc, ["grand_total", "base_grand_total"]); diff --git a/erpnext/public/js/utils/ledger_preview.js b/erpnext/public/js/utils/ledger_preview.js index 6a610dd5065..9a5c8d3217f 100644 --- a/erpnext/public/js/utils/ledger_preview.js +++ b/erpnext/public/js/utils/ledger_preview.js @@ -80,6 +80,14 @@ erpnext.accounts.ledger_preview = { }, get_datatable(columns, data, wrapper) { + columns.forEach((col) => { + if (col.fieldtype === "Currency") { + col.format = (value) => { + return format_currency(value); + }; + } + }); + const datatable_options = { columns: columns, data: data, diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 996ee949a13..002842cad2d 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -707,7 +707,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { } render_data() { - if (this.bundle || this.frm.doc.is_return) { + if (this.bundle || (this.frm.doc.is_return && this.frm.doc.return_against)) { frappe .call({ method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_serial_batch_ledgers", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index a1047c11a96..832992198b6 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1203,7 +1203,6 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a "doctype": "Sales Invoice", "field_map": { "party_account_currency": "party_account_currency", - "payment_terms_template": "payment_terms_template", }, "field_no_map": ["payment_terms_template"], "validation": {"docstatus": ["=", 1]}, diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index d38473e4b69..cff84411231 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -202,7 +202,11 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): @change_settings( "Accounts Settings", - {"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1}, + { + "add_taxes_from_item_tax_template": 0, + "add_taxes_from_taxes_and_charges_template": 1, + "automatically_fetch_payment_terms": 1, + }, ) def test_make_sales_invoice_with_terms(self): so = make_sales_order(do_not_submit=True) @@ -232,6 +236,34 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): si1 = make_sales_invoice(so.name) self.assertEqual(len(si1.get("items")), 0) + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1}) # Enable auto fetch + def test_auto_fetch_terms_enable(self): + so = make_sales_order(do_not_submit=True) + + so.payment_terms_template = "_Test Payment Term Template" + so.save() + so.submit() + + si = make_sales_invoice(so.name) + # Check if payment terms are copied from sales order to sales invoice + self.assertTrue(si.payment_terms_template) + si.insert() + si.submit() + + @change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 0}) # Disable auto fetch + def test_auto_fetch_terms_disable(self): + so = make_sales_order(do_not_submit=True) + + so.payment_terms_template = "_Test Payment Term Template" + so.save() + so.submit() + + si = make_sales_invoice(so.name) + # Check if payment terms are not copied from sales order to sales invoice + self.assertFalse(si.payment_terms_template) + si.insert() + si.submit() + def test_update_qty(self): so = make_sales_order() diff --git a/erpnext/selling/report/sales_person_commission_summary/sales_person_commission_summary.py b/erpnext/selling/report/sales_person_commission_summary/sales_person_commission_summary.py index 16b2d499af2..b0e611589eb 100644 --- a/erpnext/selling/report/sales_person_commission_summary/sales_person_commission_summary.py +++ b/erpnext/selling/report/sales_person_commission_summary/sales_person_commission_summary.py @@ -3,7 +3,8 @@ import frappe -from frappe import _, msgprint +from frappe import _, msgprint, qb +from frappe.query_builder import Criterion def execute(filters=None): @@ -97,45 +98,53 @@ def get_columns(filters): def get_entries(filters): - date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date" + dt = qb.DocType(filters["doc_type"]) + st = qb.DocType("Sales Team") + date_field = dt["transaction_date"] if filters["doc_type"] == "Sales Order" else dt["posting_date"] - conditions, values = get_conditions(filters, date_field) - entries = frappe.db.sql( - """ - select - dt.name, dt.customer, dt.territory, dt.{} as posting_date,dt.base_net_total as base_net_amount, - st.commission_rate,st.sales_person, st.allocated_percentage, st.allocated_amount, st.incentives - from - `tab{}` dt, `tabSales Team` st - where - st.parent = dt.name and st.parenttype = {} - and dt.docstatus = 1 {} order by dt.name desc,st.sales_person - """.format(date_field, filters["doc_type"], "%s", conditions), - tuple([filters["doc_type"], *values]), - as_dict=1, + conditions = get_conditions(dt, st, filters, date_field) + entries = ( + qb.from_(dt) + .join(st) + .on(st.parent.eq(dt.name) & st.parenttype.eq(filters["doc_type"])) + .select( + dt.name, + dt.customer, + dt.territory, + date_field.as_("posting_date"), + dt.base_net_total.as_("base_net_amount"), + st.commission_rate, + st.sales_person, + st.allocated_percentage, + st.allocated_amount, + st.incentives, + ) + .where(Criterion.all(conditions)) + .orderby(dt.name, st.sales_person) + .run(as_dict=True) ) return entries -def get_conditions(filters, date_field): - conditions = [""] - values = [] +def get_conditions(dt, st, filters, date_field): + conditions = [] + + conditions.append(dt.docstatus.eq(1)) + from_dt = filters.get("from_date") + to_dt = filters.get("to_date") + if from_dt and to_dt: + conditions.append(date_field.between(from_dt, to_dt)) + elif from_dt and not to_dt: + conditions.append(date_field.gte(from_dt)) + elif not from_dt and to_dt: + conditions.append(date_field.lte(to_dt)) for field in ["company", "customer", "territory"]: if filters.get(field): - conditions.append(f"dt.{field}=%s") - values.append(filters[field]) + conditions.append(dt[field].eq(filters.get(field))) if filters.get("sales_person"): - conditions.append("st.sales_person = '{}'".format(filters.get("sales_person"))) + conditions.append(st["sales_person"].eq(filters.get("sales_person"))) - if filters.get("from_date"): - conditions.append(f"dt.{date_field}>=%s") - values.append(filters["from_date"]) - - if filters.get("to_date"): - conditions.append(f"dt.{date_field}<=%s") - values.append(filters["to_date"]) - - return " and ".join(conditions), values + return conditions diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index c91b9e22632..2070b264f8f 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1015,6 +1015,102 @@ class TestPurchaseReceipt(FrappeTestCase): pr.cancel() + def test_lcv_for_internal_transfer(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + prepare_data_for_internal_transfer() + + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + item_code = make_item( + "Test Item For LCV in Internal Transfer", + {"has_batch_no": 1, "create_new_batch": 1, "batch_naming_series": "TEST-SBATCH.###"}, + ).name + + pr1 = make_purchase_receipt( + item_code=item_code, + qty=10, + rate=100, + warehouse="Stores - TCP1", + company="_Test Company with perpetual inventory", + ) + + dn1 = create_delivery_note( + item_code=pr1.items[0].item_code, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=10, + rate=500, + warehouse="Stores - TCP1", + target_warehouse="Work In Progress - TCP1", + ) + + pr = make_inter_company_purchase_receipt(dn1.name) + pr.items[0].from_warehouse = "Work In Progress - TCP1" + pr.items[0].warehouse = "Stores - TCP1" + pr.submit() + + sle_entries = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name}, + fields=["serial_and_batch_bundle", "actual_qty"], + ) + self.assertEqual(len(sle_entries), 2) + + inward_sabb = frappe.get_all( + "Serial and Batch Bundle", + filters={ + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "total_qty": (">", 0), + "docstatus": 1, + }, + pluck="name", + ) + self.assertEqual(len(inward_sabb), 1) + + original_cost = frappe.db.get_value("Serial and Batch Bundle", inward_sabb[0], "total_amount") + + make_landed_cost_voucher( + company=pr.company, + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=100, + distribute_charges_based_on="Qty", + expense_account="Expenses Included In Valuation - TCP1", + ) + + sle_entries = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0}, + fields=["serial_and_batch_bundle", "actual_qty"], + ) + self.assertEqual(len(sle_entries), 2) + + new_inward_sabb = frappe.get_all( + "Serial and Batch Bundle", + filters={ + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "total_qty": (">", 0), + "docstatus": 1, + }, + pluck="name", + ) + self.assertEqual(len(new_inward_sabb), 1) + + new_cost = frappe.db.get_value("Serial and Batch Bundle", new_inward_sabb[0], "total_amount") + self.assertEqual(new_cost, original_cost + 100) + + self.assertTrue(new_inward_sabb[0] == inward_sabb[0]) + def test_stock_transfer_from_purchase_receipt_with_valuation(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index c23ccf24083..69a82ab64cb 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -15,6 +15,7 @@ from frappe.utils import ( cint, cstr, flt, + format_datetime, get_datetime, get_link_to_form, getdate, @@ -1426,22 +1427,33 @@ class SerialandBatchBundle(Document): if flt(available_qty, precision) < 0: self.throw_negative_batch(d.batch_no, available_qty, precision) - def throw_negative_batch(self, batch_no, available_qty, precision): + def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None): from erpnext.stock.stock_ledger import NegativeStockError if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"): return + date_msg = "" + if posting_datetime: + date_msg = " " + _("as of {0}").format(format_datetime(posting_datetime)) + + msg = _( + """ + The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}. + Please add a stock quantity of {4} to proceed with this entry. + If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed. + However, enabling this setting may lead to negative stock in the system. + So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate.""" + ).format( + bold(batch_no), + bold(self.item_code), + bold(self.warehouse), + date_msg, + bold(abs(flt(available_qty, precision))), + ) + frappe.throw( - _( - """ - The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry.""" - ).format( - bold(batch_no), - bold(self.item_code), - bold(self.warehouse), - bold(abs(flt(available_qty, precision))), - ), + msg, title=_("Negative Stock Error"), exc=NegativeStockError, ) @@ -1464,7 +1476,9 @@ class SerialandBatchBundle(Document): available_qty[row.batch_no] = flt(row.qty) if flt(available_qty[row.batch_no], precision) < 0: - self.throw_negative_batch(row.batch_no, available_qty[row.batch_no], precision) + self.throw_negative_batch( + row.batch_no, available_qty[row.batch_no], precision, row.posting_datetime + ) return available_qty @@ -1498,17 +1512,17 @@ class SerialandBatchBundle(Document): def get_available_qty_from_sabb(self): batches = [d.batch_no for d in self.entries if d.batch_no] - parent = frappe.qb.DocType("Serial and Batch Bundle") + parent = frappe.qb.DocType("Stock Ledger Entry") child = frappe.qb.DocType("Serial and Batch Entry") query = ( frappe.qb.from_(parent) .inner_join(child) - .on(parent.name == child.parent) + .on(parent.serial_and_batch_bundle == child.parent) .select( child.batch_no, child.qty, - CombineDatetime(parent.posting_date, parent.posting_time).as_("posting_datetime"), + parent.posting_datetime, parent.creation, ) .where( @@ -1517,16 +1531,16 @@ class SerialandBatchBundle(Document): & (child.batch_no.isin(batches)) & (parent.docstatus == 1) & (parent.is_cancelled == 0) - & (parent.type_of_transaction.isin(["Inward", "Outward"])) ) .for_update() ) - query = query.where(parent.voucher_type != "Pick List") - return query.run(as_dict=True) def validate_voucher_no_docstatus(self): + if self.is_cancelled: + return + if self.voucher_type == "POS Invoice": return diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 9e6eb8d6f2d..ee7e652cf6e 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -542,7 +542,7 @@ }, { "default": "0", - "description": "If enabled, the system will allow negative stock entries for the batch, but this could calculate the valuation rate incorrectly, so avoid using this option.", + "description": "If enabled, the system will allow negative stock entries for the batch. But, this may lead to incorrect valuation rates, so it is recommended to avoid using this option. The system will permit negative stock only when it is caused by backdated entries and will validate and block negative stock in all other cases.", "fieldname": "allow_negative_stock_for_batch", "fieldtype": "Check", "label": "Allow Negative Stock for Batch" @@ -553,7 +553,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-02-09 15:01:12.466175", + "modified": "2026-02-16 10:36:59.921491", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/report/negative_batch_report/__init__.py b/erpnext/stock/report/negative_batch_report/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/negative_batch_report/negative_batch_report.js b/erpnext/stock/report/negative_batch_report/negative_batch_report.js new file mode 100644 index 00000000000..3bfe8fe9c85 --- /dev/null +++ b/erpnext/stock/report/negative_batch_report/negative_batch_report.js @@ -0,0 +1,41 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Negative Batch Report"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_default("company"), + }, + { + fieldname: "item_code", + label: __("Item Code"), + fieldtype: "Link", + options: "Item", + get_query: function () { + return { + filters: { + has_batch_no: 1, + }, + }; + }, + }, + { + fieldname: "warehouse", + label: __("Warehouse"), + fieldtype: "Link", + options: "Warehouse", + get_query: function () { + return { + filters: { + is_group: 0, + company: frappe.query_report.get_filter_value("company"), + }, + }; + }, + }, + ], +}; diff --git a/erpnext/stock/report/negative_batch_report/negative_batch_report.json b/erpnext/stock/report/negative_batch_report/negative_batch_report.json new file mode 100644 index 00000000000..cecc5716055 --- /dev/null +++ b/erpnext/stock/report/negative_batch_report/negative_batch_report.json @@ -0,0 +1,53 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-02-17 11:34:21.549485", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "", + "letter_head": null, + "modified": "2026-02-17 11:34:59.106045", + "modified_by": "Administrator", + "module": "Stock", + "name": "Negative Batch Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Serial and Batch Bundle", + "report_name": "Negative Batch Report", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Purchase User" + }, + { + "role": "Purchase Manager" + }, + { + "role": "Stock User" + }, + { + "role": "Stock Manager" + }, + { + "role": "Delivery User" + }, + { + "role": "Delivery Manager" + }, + { + "role": "Manufacturing User" + }, + { + "role": "Manufacturing Manager" + } + ], + "timeout": 0 +} diff --git a/erpnext/stock/report/negative_batch_report/negative_batch_report.py b/erpnext/stock/report/negative_batch_report/negative_batch_report.py new file mode 100644 index 00000000000..b12bd87d538 --- /dev/null +++ b/erpnext/stock/report/negative_batch_report/negative_batch_report.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils import add_to_date, flt, today + +from erpnext.stock.report.stock_ledger.stock_ledger import execute as stock_ledger_execute + + +def execute(filters: dict | None = None): + """Return columns and data for the report. + + This is the main entry point for the report. It accepts the filters as a + dictionary and should return columns and data. It is called by the framework + every time the report is refreshed or a filter is updated. + """ + columns = get_columns() + data = get_data(filters) + + return columns, data + + +def get_columns() -> list[dict]: + return [ + { + "label": _("Posting Datetime"), + "fieldname": "posting_date", + "fieldtype": "Datetime", + "width": 160, + }, + { + "label": _("Batch No"), + "fieldname": "batch_no", + "fieldtype": "Link", + "options": "Batch", + "width": 120, + }, + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 150, + }, + { + "label": _("Warehouse"), + "fieldname": "warehouse", + "fieldtype": "Link", + "options": "Warehouse", + "width": 160, + }, + { + "label": _("Previous Qty"), + "fieldname": "previous_qty", + "fieldtype": "Float", + "width": 130, + }, + { + "label": _("Transaction Qty"), + "fieldname": "actual_qty", + "fieldtype": "Float", + "width": 130, + }, + { + "label": _("Qty After Transaction"), + "fieldname": "qty_after_transaction", + "fieldtype": "Float", + "width": 180, + }, + { + "label": _("Document Type"), + "fieldname": "voucher_type", + "fieldtype": "Data", + "width": 130, + }, + { + "label": _("Document No"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": 130, + }, + ] + + +def get_data(filters) -> list[dict]: + batches = get_batches(filters) + companies = get_companies(filters) + batch_negative_data = [] + + flt_precision = frappe.db.get_default("float_precision") or 2 + for company in companies: + for batch in batches: + _c, data = stock_ledger_execute( + frappe._dict( + { + "company": company, + "batch_no": batch, + "from_date": add_to_date(today(), years=-12), + "to_date": today(), + "segregate_serial_batch_bundle": 1, + "warehouse": filters.get("warehouse"), + "valuation_field_type": "Currency", + } + ) + ) + + previous_qty = 0 + for row in data: + if flt(row.get("qty_after_transaction"), flt_precision) < 0: + batch_negative_data.append( + { + "posting_date": row.get("date"), + "batch_no": row.get("batch_no"), + "item_code": row.get("item_code"), + "item_name": row.get("item_name"), + "warehouse": row.get("warehouse"), + "actual_qty": row.get("actual_qty"), + "qty_after_transaction": row.get("qty_after_transaction"), + "previous_qty": previous_qty, + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + } + ) + + previous_qty = row.get("qty_after_transaction") + + return batch_negative_data + + +def get_batches(filters): + batch_filters = {} + if filters.get("item_code"): + batch_filters["item"] = filters["item_code"] + + return frappe.get_all("Batch", pluck="name", filters=batch_filters) + + +def get_companies(filters): + company_filters = {} + if filters.get("company"): + company_filters["name"] = filters["company"] + + return frappe.get_all("Company", pluck="name", filters=company_filters)