Merge branch 'develop' into fix/letterhead-footer-print-formats

This commit is contained in:
Khushi Rawat
2026-07-03 02:27:58 +05:30
committed by GitHub
175 changed files with 15525 additions and 985 deletions

View File

@@ -37,6 +37,10 @@
"account_type": "Stock",
"account_category": "Stock Assets"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Stock Assets"
},
"account_type": "Stock",
"account_category": "Stock Assets"
},
@@ -223,10 +227,6 @@
"Stock Received But Not Billed": {
"account_type": "Stock Received But Not Billed",
"account_category": "Trade Payables"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Trade Payables"
}
},
"Duties and Taxes": {

View File

@@ -471,6 +471,25 @@ def on_doctype_update():
frappe.db.add_index("GL Entry", ["posting_date", "company"])
frappe.db.add_index("GL Entry", ["party_type", "party"])
if frappe.db.db_type == "postgres":
# Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial
# Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company.
# `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are
# added only on postgres to avoid dead write overhead on this insert-hot table.
frappe.db.add_index(
"GL Entry",
["company", "posting_date", "account"],
index_name="gle_active_detail",
where="is_cancelled = 0",
)
frappe.db.add_index(
"GL Entry",
["company", "account", "posting_date"],
index_name="gle_active_cover",
where="is_cancelled = 0",
include=["debit", "credit"],
)
def rename_gle_sle_docs():
for doctype in ["GL Entry", "Stock Ledger Entry"]:

View File

@@ -29,7 +29,7 @@ frappe.ui.form.on("Journal Entry", {
refresh(frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
frm.set_read_only();
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
@@ -232,6 +232,13 @@ Object.assign(erpnext.journal_entry, {
}
},
lock_reversal_entry(frm) {
frm.fields
.filter((field) => field.has_input)
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
add_custom_buttons(frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(

View File

@@ -360,12 +360,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
totals_after_cancel = frappe.get_all(
"GL Entry",

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -33,9 +33,11 @@ class SalesInvoiceGLComposer(BaseGLComposer):
self.make_item_gl_entries(gl_entries)
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value(
"Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"]
)
if not (doc.is_return and disable_sdbnb_in_sr):
if is_sdbnb_enabled and not (doc.is_return and disable_sdbnb_in_sr):
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)

View File

@@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test Company with perpetual inventory"
company = "_Test SDBNB Company"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
make_purchase_receipt(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
qty=5,
rate=100,
)
@@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite):
dn = create_delivery_note(
company=company,
item_code="_Test FG Item",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
qty=2,
rate=300,
)
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB")
si = make_sales_invoice(dn.name)
si.insert()
@@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite):
fields=["account", "debit", "credit"],
)
sdbnb_credit = sum(
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB"
)
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB")
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
self.assertTrue(sdbnb_credit > 0)

View File

@@ -640,13 +640,15 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -715,7 +717,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date

View File

@@ -174,7 +174,17 @@ frappe.query_reports["Accounts Payable"] = {
},
get_datatable_options(options) {
return Object.assign(options, { checkboxColumn: true });
return Object.assign(options, {
checkboxColumn: true,
events: {
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report),
},
});
},
after_refresh: function (report) {
report.datatable?.rowmanager?.checkAll(false);
report.page.clear_primary_action();
},
onload: function (report) {
@@ -186,20 +196,27 @@ frappe.query_reports["Accounts Payable"] = {
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
if (frappe.model.can_create("Payment Entry")) {
report.page.add_inner_button(
__("Create Payment Entries"),
function () {
erpnext.accounts.create_payment_entries_from_payable_report(report);
},
__("Actions")
);
}
},
};
frappe.provide("erpnext.accounts");
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
const has_purchase_invoice = report.datatable.rowmanager
.getCheckedRows()
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
if (has_purchase_invoice) {
report.page.set_primary_action(__("Create Payment Entries"), () =>
erpnext.accounts.create_payment_entries_from_payable_report(report)
);
} else {
report.page.clear_primary_action();
}
};
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
const datatable = report.datatable;
if (!datatable) return;

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
"modified": "2026-06-25 12:03:36.559152",
"modified": "2026-07-01 13:37:41.185347",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -40,6 +40,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -0,0 +1,144 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.accounts_payable_summary.accounts_payable_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsPayableSummary(ERPNextTestSuite):
"""Payable Summary is a thin wrapper over AccountsReceivableSummary with
account_type=Payable; these tests lock the supplier-side output: invoiced,
advance, paid, outstanding, ageing buckets and the optional GL-balance /
future-payment columns."""
def setUp(self):
frappe.set_user("Administrator")
self.maxDiff = None
self.company = "_Test Company"
self.supplier = "_Test Supplier"
def _filters(self, **overrides):
filters = {
"company": self.company,
"supplier": self.supplier,
"posting_date": today(),
"range": "30, 60, 90, 120",
}
filters.update(overrides)
return filters
def _make_invoice(self, rate=200):
return make_purchase_invoice(
company=self.company,
supplier=self.supplier,
qty=1,
rate=rate,
price_list_rate=rate,
posting_date=today(),
)
def _expected_row(self, pi, **overrides):
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
row = {
"party_type": "Supplier",
"advance": 0,
"party": self.supplier,
"invoiced": 200.0,
"paid": 0.0,
"credit_note": 0.0,
"outstanding": 200.0,
"range1": 200.0,
"range2": 0.0,
"range3": 0.0,
"range4": 0.0,
"range5": 0.0,
"total_due": 200.0,
"future_amount": 0.0,
"sales_person": [],
"currency": pi.currency,
"supplier_group": supplier_group,
}
row.update(overrides)
return row
def test_01_payable_summary_output(self):
"""Invoiced -> advance -> partial payment progression for a single supplier."""
filters = self._filters()
pi = self._make_invoice()
expected = self._expected_row(pi)
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# advance payment: pay 50 but allocate nothing against the invoice
pe = get_payment_entry(pi.doctype, pi.name)
pe.paid_amount = 50
pe.references[0].allocated_amount = 0
pe.save().submit()
expected.update({"advance": 50.0, "outstanding": 150.0, "range1": 150.0, "total_due": 150.0})
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# partial payment allocated against the invoice
pe = get_payment_entry(pi.doctype, pi.name)
pe.paid_amount = 125
pe.references[0].allocated_amount = 125
pe.save().submit()
expected.update(
{"advance": 50.0, "paid": 125.0, "outstanding": 25.0, "range1": 25.0, "total_due": 25.0}
)
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
@ERPNextTestSuite.change_settings("Buying Settings", {"supp_master_name": "Naming Series"})
def test_02_gl_balance_and_future_payment_columns(self):
"""Naming-series naming adds party_name; show_gl_balance / show_future_payments
add their columns; a fully-paid invoice drops out of the report."""
filters = self._filters()
pi = self._make_invoice()
pe = get_payment_entry(pi.doctype, pi.name)
pe.paid_amount = 150
pe.references[0].allocated_amount = 150
pe.save().submit()
expected = self._expected_row(
pi,
party_name=frappe.db.get_value("Supplier", self.supplier, "supplier_name"),
paid=150.0,
outstanding=50.0,
range1=50.0,
total_due=50.0,
)
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# GL balance reconciliation columns
filters.update({"show_gl_balance": True})
expected.update({"gl_balance": 50.0, "diff": 0.0})
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# future payment columns
filters.update({"show_future_payments": True})
expected.update({"remaining_balance": 50.0})
rows = execute(filters)[1]
self.assertEqual(len(rows), 1)
self.assertDictEqual(rows[0], expected)
# clear the remaining balance -> supplier drops out of the summary entirely
get_payment_entry(pi.doctype, pi.name).save().submit()
rows = execute(filters)[1]
self.assertEqual(len(rows), 0)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 5,
"is_standard": "Yes",
"modified": "2026-06-25 12:03:28.812092",
"modified": "2026-07-01 13:37:44.167999",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -34,6 +34,6 @@
"role": "Accounts User"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -277,7 +277,7 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
return chart
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.bank_clearance_summary.bank_clearance_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
BANK_ACCOUNT = "_Test Bank - _TC"
class TestBankClearanceSummary(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"account": BANK_ACCOUNT,
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, payment_entry):
for row in data:
if row[1] == payment_entry:
return row
return None
def test_uncleared_then_cleared_journal_entry(self):
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 5000, submit=True, posting_date="2026-06-01")
# Uncleared: the bank row appears with the debit amount and no clearance date
row = self.find_row(self.run_report(), je.name)
self.assertIsNotNone(row, "Journal Entry not listed in Bank Clearance Summary")
self.assertEqual(row[0], "Journal Entry")
self.assertEqual(frappe.utils.getdate(row[2]), frappe.utils.getdate("2026-06-01"))
self.assertIsNone(row[4]) # clearance_date empty -> uncleared
self.assertEqual(row[5], "Sales - _TC") # against account
self.assertEqual(row[6], 5000) # debit - credit on the bank account
# Cleared: set the clearance date on the Journal Entry and re-run
frappe.db.set_value("Journal Entry", je.name, "clearance_date", "2026-06-05")
row = self.find_row(self.run_report(), je.name)
self.assertIsNotNone(row)
self.assertEqual(frappe.utils.getdate(row[4]), frappe.utils.getdate("2026-06-05"))
self.assertEqual(row[6], 5000)
def test_date_filter_excludes_out_of_range_entries(self):
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 3000, submit=True, posting_date="2026-06-10")
# Within range: present
self.assertIsNotNone(self.find_row(self.run_report(), je.name))
# Window entirely after the posting date (from_date lower bound): excluded
after = self.run_report(from_date="2026-07-01", to_date="2026-12-31")
self.assertIsNone(self.find_row(after, je.name))
# Window ending before the posting date (to_date upper bound): excluded
before = self.run_report(from_date="2026-01-01", to_date="2026-06-09")
self.assertIsNone(self.find_row(before, je.name))

View File

@@ -31,7 +31,7 @@ def get_report_filters(report_filters):
]
if report_filters.get("purchase_invoice"):
filters.append(["Purchase Invoice", "per_received", "in", [report_filters.get("purchase_invoice")]])
filters.append(["Purchase Invoice", "name", "=", report_filters.get("purchase_invoice")])
return filters

View File

@@ -0,0 +1,102 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.billed_items_to_be_received.billed_items_to_be_received import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestBilledItemsToBeReceived(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": today(),
}
)
filters.update(extra)
return execute(filters)[1]
def get_rows_for(self, data, pi_name):
return [row for row in data if row.get("name") == pi_name]
def test_billed_but_not_received_item_appears(self):
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=0,
)
rows = self.get_rows_for(self.run_report(), pi.name)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.get("supplier"), "_Test Supplier")
self.assertEqual(row.get("company"), "_Test Company")
self.assertEqual(row.get("item_code"), "_Test Item")
self.assertEqual(row.get("qty"), 5)
self.assertEqual(row.get("received_qty"), 0)
self.assertEqual(row.get("rate"), 200)
self.assertEqual(row.get("amount"), 1000)
def test_stock_updating_invoice_is_excluded(self):
"""update_stock=1 means the item is already received; it must not appear."""
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=1,
)
rows = self.get_rows_for(self.run_report(), pi.name)
self.assertEqual(len(rows), 0)
def test_fully_received_invoice_drops_off(self):
"""When per_received reaches 100 the invoice is fully received and drops off."""
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=0,
)
# Present while nothing has been received.
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 1)
frappe.db.set_value("Purchase Invoice", pi.name, "per_received", 100)
# Absent once fully received.
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 0)
def test_posting_date_upper_bound_filter(self):
"""A PI posted after the filter's posting_date must be excluded."""
pi = make_purchase_invoice(
supplier="_Test Supplier",
item_code="_Test Item",
qty=5,
rate=200,
update_stock=0,
)
rows = self.get_rows_for(self.run_report(posting_date="2000-01-01"), pi.name)
self.assertEqual(len(rows), 0)
def test_purchase_invoice_filter_scopes_to_that_invoice(self):
"""The optional purchase_invoice filter must narrow to that invoice only."""
pi = make_purchase_invoice(
supplier="_Test Supplier", item_code="_Test Item", qty=5, rate=200, update_stock=0
)
other = make_purchase_invoice(
supplier="_Test Supplier", item_code="_Test Item", qty=3, rate=200, update_stock=0
)
names = {row.get("name") for row in self.run_report(purchase_invoice=pi.name)}
self.assertEqual(names, {pi.name})
self.assertNotIn(other.name, names)

View File

@@ -2,26 +2,116 @@
# For license information, please see license.txt
import frappe
from frappe.utils import nowdate
from erpnext.accounts.doctype.budget.test_budget import make_budget, set_total_expense_zero
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.budget_variance_report.budget_variance_report import execute
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
ACCOUNT = "_Test Account Cost for Goods Sold - _TC"
COST_CENTER = "_Test Cost Center - _TC"
COST_CENTER_2 = "_Test Cost Center 2 - _TC"
class TestBudgetVarianceReport(ERPNextTestSuite):
def setUp(self):
self.fy = get_fiscal_year(nowdate())[0]
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_fiscal_year": self.fy,
"to_fiscal_year": self.fy,
"period": "Yearly",
"budget_against": "Cost Center",
**extra,
}
)
return execute(filters)[1]
def report_row(self, data, dimension, account=ACCOUNT):
row = next(
(r for r in data if r["budget_against"] == dimension and r["account"] == account),
None,
)
self.assertIsNotNone(row, f"No report row for {dimension} / {account}")
return row
def field(self, label):
return frappe.scrub(f"{label} {self.fy}")
def test_report_executes(self):
# Smoke-guards the raw-SQL -> query-builder port: the report query must compile and run on
# both MariaDB and postgres.
company = frappe.db.get_value("Company", {}, "name")
fy = frappe.db.get_value("Fiscal Year", {}, "name", order_by="year_start_date desc")
columns, *_rest = execute(
frappe._dict(
{
"company": company,
"from_fiscal_year": fy,
"to_fiscal_year": fy,
"company": "_Test Company",
"from_fiscal_year": self.fy,
"to_fiscal_year": self.fy,
"period": "Yearly",
"budget_against": "Cost Center",
}
)
)
self.assertTrue(columns)
def test_budget_amount_shown_with_zero_actual(self):
# neutralise any committed actuals so the exact Actual/Variance assertions hold
set_total_expense_zero(nowdate(), "cost_center")
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
row = self.report_row(self.run_report(), COST_CENTER)
self.assertEqual(row[self.field("Budget")], 120000)
self.assertEqual(row[self.field("Actual")], 0)
self.assertEqual(row[self.field("Variance")], 120000)
def test_actual_expense_updates_actual_and_variance(self):
# zero out pre-committed actuals: keeps Actual exact and avoids the budget's
# "Stop" action rejecting the journal entry when prior actuals already exist
set_total_expense_zero(nowdate(), "cost_center")
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
# book an actual expense well within the annual budget so the "Stop" action does not block it
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
row = self.report_row(self.run_report(), COST_CENTER)
self.assertEqual(row[self.field("Actual")], 50000)
self.assertEqual(row[self.field("Variance")], 70000) # 120000 - 50000
def test_budget_against_filter_limits_dimensions(self):
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER_2, budget_amount=80000, submit_budget=1
)
data = self.run_report(budget_against_filter=[COST_CENTER])
dimensions = {row["budget_against"] for row in data}
self.assertEqual(dimensions, {COST_CENTER})
def test_monthly_period_totals(self):
# zero out pre-committed actuals so total_actual reflects only this test's entry
set_total_expense_zero(nowdate(), "cost_center")
make_budget(
budget_against="Cost Center", cost_center=COST_CENTER, budget_amount=120000, submit_budget=1
)
make_journal_entry(ACCOUNT, "_Test Bank - _TC", 50000, cost_center=COST_CENTER, submit=True)
row = self.report_row(self.run_report(period="Monthly"), COST_CENTER)
# totals roll up the per-month columns across the year
self.assertEqual(row["total_budget"], 120000)
self.assertEqual(row["total_actual"], 50000)
self.assertEqual(row["total_variance"], 70000)
def test_no_budget_returns_no_rows(self):
# a dimension without any budget produces no report rows
data = self.run_report(budget_against_filter=["_Test Write Off Cost Center - _TC"])
self.assertEqual(data, [])

View File

@@ -0,0 +1,82 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
import frappe
from frappe.utils.formatters import format_value
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.calculated_discount_mismatch.calculated_discount_mismatch import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCalculatedDiscountMismatch(ERPNextTestSuite):
"""Integrity detector: flag transactions whose stored ``discount_amount`` was tampered
after the fact (a Version records the change) while ``additional_discount_percentage``
stayed the same, so the stored amount no longer matches the percentage-derived value.
"""
def run_report(self, docname: str) -> dict | None:
"""Run the (filter-less) report and return the row for ``docname``, if any."""
_columns, data = execute(frappe._dict({}))
return next((row for row in data if row["docname"] == docname), None)
def create_discounted_invoice(self) -> "frappe.Document":
"""Draft Sales Invoice (rate 1000) with a 10% additional discount.
The controller derives ``discount_amount`` = 10% of the grand total = 100.00,
so the stored amount is consistent with the percentage.
"""
invoice = create_sales_invoice(rate=1000, qty=1, do_not_submit=1)
invoice.additional_discount_percentage = 10
invoice.save()
invoice.reload()
return invoice
def test_consistent_discount_is_not_flagged(self):
"""A submitted invoice whose discount_amount matches its percentage is not reported."""
invoice = self.create_discounted_invoice()
invoice.submit()
invoice.reload()
self.assertEqual(invoice.discount_amount, 100.0)
self.assertIsNone(self.run_report(invoice.name))
def test_tampered_discount_is_flagged(self):
"""Directly overwriting discount_amount (leaving the percentage intact) is reported.
This reproduces the real-world integrity breach: a Version records the
``discount_amount`` change, its ``new`` value equals the current stored amount, and
``additional_discount_percentage`` was not touched -- exactly the shape the report
queries for.
"""
invoice = self.create_discounted_invoice()
consistent_amount = invoice.discount_amount # 100.00, matches the 10% percentage
tampered_amount = 250.0
discount_field = frappe.get_meta("Sales Invoice").get_field("discount_amount")
# Format exactly as the report does so version.new == format_value(current amount).
suspected = format_value(consistent_amount, df=discount_field, currency=invoice.currency)
actual = format_value(tampered_amount, df=discount_field, currency=invoice.currency)
# Tamper the stored amount directly, bypassing the controller that would recompute it.
frappe.db.set_value("Sales Invoice", invoice.name, "discount_amount", tampered_amount)
self.record_discount_change(invoice.name, suspected, actual)
row = self.run_report(invoice.name)
self.assertIsNotNone(row)
self.assertEqual(row["doctype"], "Sales Invoice")
self.assertEqual(row["actual_discount_percentage"], 10.0)
self.assertEqual(row["actual_discount_amount"], actual)
self.assertEqual(row["suspected_discount_amount"], suspected)
def record_discount_change(self, docname: str, old: str, new: str) -> None:
"""Insert the Version audit row a direct discount_amount edit would have produced."""
version = frappe.new_doc("Version")
version.ref_doctype = "Sales Invoice"
version.docname = docname
version.data = json.dumps({"changed": [["discount_amount", old, new]]}, separators=(",", ":"))
version.flags.ignore_version = True
version.insert(ignore_permissions=True)

View File

@@ -582,7 +582,12 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
total += flt(row[company])
row["has_value"] = has_value
row["total"] = total
# when accumulating into the group company, that company's column already consolidates its
# descendants, so summing every company column would double-count; use the group total directly.
if filters.get("accumulated_in_group_company"):
row["total"] = flt(row.get(filters.company, 0.0), 3)
else:
row["total"] = total
data.append(row)

View File

@@ -0,0 +1,129 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt, today
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
execute,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
PARENT_COMPANY = "Parent Group Company India"
CHILD_COMPANY = "Child Company India"
class TestConsolidatedFinancialStatement(ERPNextTestSuite):
"""Consolidation is exercised via the bootstrap group of companies
(`Parent Group Company India` with child `Child Company India`). Income and
expense posted in the child company must surface in the report that is run
for the parent (group) company."""
def setUp(self):
self.fiscal_year = get_fiscal_year(today(), company=PARENT_COMPANY)[0]
def run_report(self, **extra):
filters = frappe._dict(
{
"company": PARENT_COMPANY,
"filter_based_on": "Fiscal Year",
"from_fiscal_year": self.fiscal_year,
"to_fiscal_year": self.fiscal_year,
"periodicity": "Yearly",
"include_default_book_entries": 1,
}
)
filters.update(extra)
return execute(filters)[1]
def post_journal_entry(self, debit_account, credit_account, amount):
je = frappe.new_doc("Journal Entry")
je.posting_date = today()
je.company = CHILD_COMPANY
je.set(
"accounts",
[
{"account": debit_account, "debit_in_account_currency": amount},
{"account": credit_account, "credit_in_account_currency": amount},
],
)
je.save()
je.submit()
return je
def get_row(self, data, account_name_fragment, last_match=False):
"""Return the first (or last) row whose account_name contains the fragment.
Pass ``last_match=True`` to get the leaf/most-specific match when the fragment
is also a prefix of a parent group account (parents precede children in tree order).
"""
found = None
for row in data:
if account_name_fragment in str(row.get("account_name") or ""):
if not last_match:
return row
found = row
return found
def test_profit_and_loss_reflects_child_company_income(self):
amount = 7000
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
self.assertTrue(data, "Report returned no rows")
# child's Sales account is mapped onto the parent chart (Sales - PGCI)
sales_row = self.get_row(data, "Sales", last_match=True)
self.assertIsNotNone(sales_row, "Sales row missing from consolidated P&L")
# >= so a pre-existing Sales balance in the fiscal year doesn't make this brittle
self.assertGreaterEqual(flt(sales_row.get(CHILD_COMPANY)), amount)
total_income_row = self.get_row(data, "Total Income (Credit)")
self.assertIsNotNone(total_income_row, "Total Income row missing")
self.assertGreaterEqual(flt(total_income_row.get("total")), amount)
def test_profit_and_loss_reflects_child_company_expense(self):
amount = 3000
self.post_journal_entry("Marketing Expenses - CCI", "Cash - CCI", amount)
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
expense_row = self.get_row(data, "Marketing Expenses", last_match=True)
self.assertIsNotNone(expense_row, "Marketing Expenses row missing from consolidated P&L")
self.assertGreaterEqual(flt(expense_row.get(CHILD_COMPANY)), amount)
total_expense_row = self.get_row(data, "Total Expense (Debit)")
self.assertIsNotNone(total_expense_row, "Total Expense row missing")
self.assertGreaterEqual(flt(total_expense_row.get("total")), amount)
def test_accumulated_in_group_company_rolls_up_to_parent(self):
"""With `accumulated_in_group_company`, the child's amount is also
accumulated into the parent company column."""
amount = 5000
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=1)
sales_row = self.get_row(data, "Sales", last_match=True)
self.assertIsNotNone(sales_row)
child_value = flt(sales_row.get(CHILD_COMPANY))
self.assertGreaterEqual(child_value, amount)
# parent column picks up the child value when accumulated
self.assertEqual(flt(sales_row.get(PARENT_COMPANY)), child_value)
# the total equals the consolidated (group) value, not the sum of parent + child
# columns -- this is the regression guard for the double-count fix
self.assertEqual(flt(sales_row.get("total")), child_value)
def test_balance_sheet_executes_and_returns_rows(self):
# posting income leaves a balancing entry in the child's Cash (Asset) account
amount = 4000
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
data = self.run_report(report="Balance Sheet", accumulated_in_group_company=0)
self.assertTrue(data, "Balance Sheet returned no rows")
cash_row = self.get_row(data, "Cash")
self.assertIsNotNone(cash_row, "Cash asset row missing from consolidated Balance Sheet")
self.assertGreaterEqual(flt(cash_row.get(CHILD_COMPANY)), amount)

View File

@@ -0,0 +1,94 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.custom_financial_statement.custom_financial_statement import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCustomFinancialStatement(ERPNextTestSuite):
"""The report renders a Financial Report Template through FinancialReportEngine.
These tests exercise its own entry point: a template with an account-data row
and a calculated row, and the guard that returns nothing without a template."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
self.cash_account = "Cash - _TC"
def _make_template(self):
# rows filter by exact account name so the value is isolated from other data
template_name = f"Test Custom FS {frappe.generate_hash()[:8]}"
return frappe.get_doc(
{
"doctype": "Financial Report Template",
"template_name": template_name,
"report_type": "Profit and Loss Statement",
"rows": [
{
"reference_code": "EXP",
"display_name": "Test Expense",
"indentation_level": 0,
"data_source": "Account Data",
"balance_type": "Closing Balance",
"calculation_formula": f'["name", "=", "{self.expense_account}"]',
},
{
"reference_code": "EXP_X2",
"display_name": "Expense Doubled",
"indentation_level": 0,
"data_source": "Calculated Amount",
"calculation_formula": "EXP * 2",
},
],
}
).insert()
def _filters(self, template_name):
return frappe._dict(
{
"company": self.company,
"report_template": template_name,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-12-31",
"filter_based_on": "Date Range",
"periodicity": "Yearly",
"accumulated_values": 0,
}
)
def test_account_and_calculated_rows(self):
make_journal_entry(
self.expense_account,
self.cash_account,
2000,
posting_date="2024-06-15",
company=self.company,
submit=True,
)
template = self._make_template()
columns, data = execute(self._filters(template.template_name))[:2]
self.assertTrue(columns)
rows = {row.get("account_name"): row for row in data}
self.assertIn("Test Expense", rows)
self.assertIn("Expense Doubled", rows)
period_keys = rows["Test Expense"].get("_segment_info", {}).get("period_keys", [])
self.assertTrue(period_keys, "expected at least one period key in _segment_info")
period_key = period_keys[0]
# the account-data row picks up the posted expense; the calculated row doubles it
self.assertEqual(flt(rows["Test Expense"][period_key]), 2000.0)
self.assertEqual(flt(rows["Expense Doubled"][period_key]), 4000.0)
def test_no_template_returns_nothing(self):
"""Without a report_template the report short-circuits and returns None."""
self.assertIsNone(execute(frappe._dict({"company": self.company})))

View File

@@ -0,0 +1,89 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.delivered_items_to_be_billed.delivered_items_to_be_billed import execute
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestDeliveredItemsToBeBilled(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": "2026-06-30",
}
)
filters.update(extra)
return execute(filters)[1]
def stock_up_item(self):
make_stock_entry(
item_code="_Test Item",
target="Stores - _TC",
qty=20,
basic_rate=100,
posting_date="2026-05-25",
)
def test_unbilled_delivery_note_appears(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-06-01",
)
rows = self.run_report(delivery_note=dn.name)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.name, dn.name)
self.assertEqual(row.customer, "_Test Customer")
self.assertEqual(row.item_code, "_Test Item")
self.assertEqual(row.amount, 1500)
self.assertEqual(row.billed_amount, 0)
self.assertEqual(row.returned_amount, 0)
self.assertEqual(row.pending_amount, 1500)
def test_fully_billed_delivery_note_drops_out(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-06-01",
)
self.assertEqual(len(self.run_report(delivery_note=dn.name)), 1)
si = make_sales_invoice(dn.name)
si.posting_date = "2026-06-02"
si.set_posting_time = 1
si.insert()
si.submit()
self.assertEqual(self.run_report(delivery_note=dn.name), [])
def test_date_filter_excludes_later_delivery_notes(self):
self.stock_up_item()
dn = create_delivery_note(
item_code="_Test Item",
warehouse="Stores - _TC",
qty=5,
rate=300,
customer="_Test Customer",
posting_date="2026-07-15",
)
rows = self.run_report(delivery_note=dn.name, posting_date="2026-06-30")
self.assertEqual(rows, [])

View File

@@ -0,0 +1,82 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.dimension_wise_accounts_balance_report.dimension_wise_accounts_balance_report import (
execute,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
class TestDimensionWiseAccountsBalance(ERPNextTestSuite):
"""Balances accounts one column per value of an accounting dimension (here
Cost Center). Locks the two behaviours that matter: an entry lands in its
own dimension column as debit - credit, and children roll up into parents."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
self.cash_account = "Cash - _TC"
def _make_cost_center(self, name):
full_name = f"{name} - _TC"
if not frappe.db.exists("Cost Center", full_name):
frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": name,
"parent_cost_center": "_Test Company - _TC",
"company": self.company,
"is_group": 0,
}
).insert()
return full_name
def _filters(self, **overrides):
filters = frappe._dict(
{
"company": self.company,
"dimension": "Cost Center",
"fiscal_year": get_fiscal_year(today(), company=self.company)[0],
}
)
filters.update(overrides)
return filters
def test_dimension_column_and_rollup(self):
# a dedicated cost center isolates our column from any other posted data
cost_center = self._make_cost_center("Test Dimension CC")
make_journal_entry(
self.expense_account,
self.cash_account,
300,
cost_center=cost_center,
posting_date=today(),
submit=True,
)
columns, data = execute(self._filters())
column = frappe.scrub(cost_center)
self.assertIn(column, [c["fieldname"] for c in columns])
rows = {row["account"]: row for row in data}
# the entry shows as debit - credit under its own dimension column
self.assertEqual(rows[self.expense_account][column], 300.0)
self.assertEqual(rows[self.cash_account][column], -300.0)
# and rolls up into each account's parent (isolated to our cost center)
expense_parent = frappe.db.get_value("Account", self.expense_account, "parent_account")
cash_parent = frappe.db.get_value("Account", self.cash_account, "parent_account")
self.assertEqual(rows[expense_parent][column], 300.0)
self.assertEqual(rows[cash_parent][column], -300.0)
def test_requires_fiscal_year(self):
filters = self._filters()
filters.pop("fiscal_year")
self.assertRaises(frappe.ValidationError, execute, filters)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:35.057216",
"modified": "2026-07-01 13:36:06.682661",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -820,7 +820,7 @@ def get_columns(filters):
return columns
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):

View File

@@ -0,0 +1,85 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.gross_and_net_profit_report.gross_and_net_profit_report import execute
from erpnext.tests.utils import ERPNextTestSuite
BANK = "_Test Bank - _TC"
INCOME_PARENT = "Income - _TC"
EXPENSE_PARENT = "Expenses - _TC"
# bootstrap leaf accounts that already have include_in_gross = 0 (no creation needed)
NON_GROSS_INCOME = "_Test Account Sales - _TC"
NON_GROSS_EXPENSE = "_Test Account Cost for Goods Sold - _TC"
# an isolated fiscal year so other accounts contribute nothing to the totals
FY = "_Test Fiscal Year 2049"
DATE = "2049-06-01"
class TestGrossAndNetProfitReport(ERPNextTestSuite):
def run_report(self, from_fiscal_year=FY, to_fiscal_year=FY):
filters = frappe._dict(
{
"company": "_Test Company",
"filter_based_on": "Fiscal Year",
"from_fiscal_year": from_fiscal_year,
"to_fiscal_year": to_fiscal_year,
"period_start_date": "2049-01-01",
"period_end_date": "2049-12-31",
"periodicity": "Yearly",
"accumulated_values": 0,
"presentation_currency": None,
}
)
return execute(filters)[1]
def make_account(self, name, parent, include_in_gross):
account = create_account(account_name=name, parent_account=parent, company="_Test Company")
frappe.db.set_value("Account", account, "include_in_gross", include_in_gross)
return account
def book_income(self, account, amount):
make_journal_entry(BANK, account, amount, posting_date=DATE, submit=True)
def book_expense(self, account, amount):
make_journal_entry(account, BANK, amount, posting_date=DATE, submit=True)
def report_row(self, data, account):
return next(row for row in data if row.get("account") == account)
def test_gross_profit_excludes_non_gross_accounts(self):
# reuse bootstrap accounts for the non-gross (include_in_gross = 0) side
gross_income = self.make_account("_Test GNP Gross Income", INCOME_PARENT, include_in_gross=1)
gross_expense = self.make_account("_Test GNP Gross Expense", EXPENSE_PARENT, include_in_gross=1)
self.book_income(gross_income, 10000)
self.book_income(NON_GROSS_INCOME, 2000)
self.book_expense(gross_expense, 4000)
self.book_expense(NON_GROSS_EXPENSE, 1000)
data = self.run_report()
# gross profit only counts include_in_gross accounts: 10000 - 4000
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 6000)
# net profit counts everything: (10000 + 2000) - (4000 + 1000)
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 7000)
def test_net_profit_equals_gross_when_all_included(self):
income = self.make_account("_Test GNP All Income", INCOME_PARENT, include_in_gross=1)
expense = self.make_account("_Test GNP All Expense", EXPENSE_PARENT, include_in_gross=1)
self.book_income(income, 9000)
self.book_expense(expense, 5000)
data = self.run_report()
self.assertEqual(self.report_row(data, "'Gross Profit'")["total"], 4000)
self.assertEqual(self.report_row(data, "'Net Profit'")["total"], 4000)
def test_nothing_included_in_gross_when_no_entries(self):
# a fiscal year with no income/expense entries yields the placeholder row
data = self.run_report(
from_fiscal_year="_Test Fiscal Year 2048", to_fiscal_year="_Test Fiscal Year 2048"
)
self.assertEqual(data[0]["account"], "'Nothing is included in gross'")

View File

@@ -84,7 +84,8 @@ def build_query_filters(filters: dict | None = None) -> list:
qb_filters = []
if filters:
if filters.account:
qb_filters.append(qb.Field("account").isin(filters.account))
accounts = filters.account if isinstance(filters.account, list | tuple) else [filters.account]
qb_filters.append(qb.Field("account").isin(accounts))
if filters.voucher_no:
qb_filters.append(qb.Field("voucher_no").eq(filters.voucher_no))

View File

@@ -0,0 +1,152 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import qb
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.invalid_ledger_entries.invalid_ledger_entries import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestInvalidLedgerEntries(ERPNextTestSuite):
"""Tests for the Invalid Ledger Entries integrity report.
The report flags vouchers that still have *active* ledger entries
(GL Entry with is_cancelled=0 or Payment Ledger Entry with delinked=0)
in the given period, but whose source voucher document is no longer
submitted (docstatus != 1). Such orphaned ledgers indicate corruption.
"""
def setUp(self):
self.company = "_Test Company"
self.debit_account = "_Test Bank - _TC"
self.credit_account = "_Test Cash - _TC"
self.from_date = "2026-01-01"
self.to_date = "2026-12-31"
self.posting_date = "2026-06-01"
def run_report(self, **extra):
filters = frappe._dict(
{
"company": self.company,
"from_date": self.from_date,
"to_date": self.to_date,
}
)
filters.update(extra)
return execute(filters)[1]
def make_submitted_jv(self):
return make_journal_entry(
self.debit_account,
self.credit_account,
amount=500,
posting_date=self.posting_date,
company=self.company,
submit=True,
)
def test_healthy_voucher_not_flagged(self):
"""A normal balanced, submitted Journal Entry must NOT be flagged."""
jv = self.make_submitted_jv()
# It genuinely posted active GL entries, so it is in scope of the scan.
self.assertTrue(
frappe.db.exists(
"GL Entry",
{"voucher_no": jv.name, "is_cancelled": 0, "company": self.company},
)
)
flagged = {row.get("voucher_no") for row in self.run_report()}
self.assertNotIn(jv.name, flagged)
def test_orphaned_gl_entries_flagged(self):
"""A voucher whose document was set non-submitted while its GL entries
remain active (is_cancelled=0) must be flagged as invalid."""
jv = self.make_submitted_jv()
# Corrupt the state: mark the source document as cancelled (docstatus=2)
# without cancelling/removing its GL Entries. This is the exact orphaned
# ledger condition the report detects.
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
data = self.run_report()
matching = [
row
for row in data
if row.get("voucher_no") == jv.name and row.get("voucher_type") == "Journal Entry"
]
self.assertEqual(len(matching), 1, "Orphaned voucher should be flagged exactly once")
self.assertEqual(matching[0]["voucher_type"], "Journal Entry")
self.assertEqual(matching[0]["voucher_no"], jv.name)
def test_voucher_no_filter_scopes_scan(self):
"""The voucher_no filter must restrict the scan to that voucher only."""
orphan = self.make_submitted_jv()
other = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
frappe.db.set_value("Journal Entry", other.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report(voucher_no=orphan.name)}
self.assertIn(orphan.name, flagged)
self.assertNotIn(other.name, flagged)
def test_account_filter_scopes_scan(self):
"""The account filter (a MultiSelectList, so a list) must restrict the
scan to vouchers touching one of the given accounts."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
# Filtering on an account the voucher touches -> flagged.
flagged = {row.get("voucher_no") for row in self.run_report(account=[self.debit_account])}
self.assertIn(orphan.name, flagged)
# Filtering on an unrelated account -> not in scope.
unrelated = "Creditors - _TC"
flagged = {row.get("voucher_no") for row in self.run_report(account=[unrelated])}
self.assertNotIn(orphan.name, flagged)
def test_account_filter_accepts_a_scalar(self):
"""A scalar (non-list) account filter must not crash the query."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report(account=self.debit_account)}
self.assertIn(orphan.name, flagged)
def test_period_filter_excludes_out_of_range(self):
"""Vouchers posted outside the from/to window must not be scanned."""
orphan = self.make_submitted_jv()
frappe.db.set_value("Journal Entry", orphan.name, "docstatus", 2, update_modified=False)
flagged = {
row.get("voucher_no") for row in self.run_report(from_date="2025-01-01", to_date="2025-12-31")
}
self.assertNotIn(orphan.name, flagged)
def test_cancelled_gl_entries_not_flagged(self):
"""If the ledger entries are properly cancelled (is_cancelled=1), the
voucher is out of scope even when its document is non-submitted."""
jv = self.make_submitted_jv()
gle = qb.DocType("GL Entry")
qb.update(gle).set(gle.is_cancelled, 1).where(gle.voucher_no == jv.name).run()
frappe.db.set_value("Journal Entry", jv.name, "docstatus", 2, update_modified=False)
flagged = {row.get("voucher_no") for row in self.run_report()}
self.assertNotIn(jv.name, flagged)
def test_missing_filters_raises(self):
"""validate_filters must guard mandatory inputs."""
self.assertRaises(frappe.ValidationError, execute, None)
bad = frappe._dict({"from_date": self.from_date, "to_date": self.to_date})
self.assertRaises(frappe.ValidationError, execute, bad)
reversed_dates = frappe._dict(
{"company": self.company, "from_date": self.to_date, "to_date": self.from_date}
)
self.assertRaises(frappe.ValidationError, execute, reversed_dates)

View File

@@ -21,6 +21,13 @@ def execute(filters=None):
entries = get_entries(filters)
invoice_details = get_invoice_posting_date_map(filters)
# Only four range columns are defined (range1-range4, the last being "90 Above").
# Three thresholds yield exactly four buckets, so payments more than 90 days after
# the invoice land in range4 instead of an unread range5.
report_filters = frappe._dict(filters)
report_filters.range = "30, 60, 90"
report = ReceivablePayableReport(report_filters)
data = []
for d in entries:
invoice = invoice_details.get(d.against_voucher_no) or frappe._dict()
@@ -29,7 +36,9 @@ def execute(filters=None):
d.update({"range1": 0, "range2": 0, "range3": 0, "range4": 0, "outstanding": payment_amount})
if d.against_voucher_no:
ReceivablePayableReport(filters).get_ageing_data(invoice.posting_date, d)
# age the payment by how long after the invoice it was made (payment date - invoice date)
report.age_as_on = getdate(d.posting_date)
report.get_ageing_data(invoice.posting_date, d)
row = [
d.voucher_type,

View File

@@ -0,0 +1,140 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import getdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.payment_period_based_on_invoice_date.payment_period_based_on_invoice_date import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
"""Depth tests for the Payment Period Based On Invoice Date report.
The report lists Payment Ledger Entries against invoices and buckets the paid
amount by the payment period -- how long after the invoice the payment was made
(payment date - invoice date) -- into ranges: range1 (0-30), range2 (30-60),
range3 (60-90), range4 (90 Above).
"""
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"payment_type": "Incoming",
"party_type": "Customer",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
columns, data = execute(filters)
fieldnames = [c["fieldname"] for c in columns]
# Map each positional row to a dict keyed by column fieldname so assertions
# stay correct even if a column is inserted or reordered.
return columns, [dict(zip(fieldnames, row, strict=False)) for row in data]
def find_payment_row(self, data, payment_name):
for row in data:
if row["payment_entry"] == payment_name:
return row
return None
def pay_invoice(self, invoice, payment_date):
pe = get_payment_entry("Sales Invoice", invoice.name)
pe.posting_date = payment_date
pe.reference_no = "1"
pe.reference_date = payment_date
pe.submit()
return pe
def test_paid_amount_lands_in_0_30_bucket(self):
# invoice 2026-06-01, paid 2026-06-20 -> 19 days after -> 0-30 bucket
invoice = create_sales_invoice(customer="_Test Customer", rate=1000, posting_date="2026-06-01")
payment = self.pay_invoice(invoice, "2026-06-20")
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row["party_type"], "Customer")
self.assertEqual(row["posting_date"], getdate("2026-06-20"))
self.assertEqual(row["invoice"], invoice.name)
self.assertEqual(row["invoice_posting_date"], getdate("2026-06-01"))
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 19) # age = payment date - invoice date
# Buckets: 0-30 filled, others empty.
self.assertEqual(row["range1"], 1000) # 0-30
self.assertEqual(row["range2"], 0) # 30-60
self.assertEqual(row["range3"], 0) # 60-90
self.assertEqual(row["range4"], 0) # 90 Above
def test_paid_amount_lands_in_30_60_bucket(self):
# invoice 2026-06-01, paid 2026-07-16 -> 45 days after -> 30-60 bucket
invoice = create_sales_invoice(customer="_Test Customer 1", rate=1000, posting_date="2026-06-01")
payment = self.pay_invoice(invoice, "2026-07-16")
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 45)
# Buckets: 30-60 filled, others empty.
self.assertEqual(row["range1"], 0)
self.assertEqual(row["range2"], 1000)
self.assertEqual(row["range3"], 0)
self.assertEqual(row["range4"], 0)
def test_payment_over_90_days_lands_in_90_above_bucket(self):
# invoice 2026-01-01, paid 2026-06-01 -> 151 days after -> "90 Above" bucket.
# Regression guard: with four range columns, a payment older than the last
# threshold must fall into range4 rather than an unread range5 (showing 0).
invoice = create_sales_invoice(customer="_Test Customer 2", rate=1000, posting_date="2026-01-01")
payment = self.pay_invoice(invoice, "2026-06-01")
_columns, data = self.run_report()
row = self.find_payment_row(data, payment.name)
self.assertIsNotNone(row, "Payment row not found in report output")
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["age"], 151)
self.assertEqual(row["range1"], 0)
self.assertEqual(row["range2"], 0)
self.assertEqual(row["range3"], 0)
self.assertEqual(row["range4"], 1000) # 90 Above captures the full amount
def test_columns_expose_expected_age_buckets(self):
columns, _data = self.run_report()
labels_by_fieldname = {c["fieldname"]: c["label"] for c in columns}
self.assertEqual(labels_by_fieldname["range1"], "0-30")
self.assertEqual(labels_by_fieldname["range2"], "30-60")
self.assertEqual(labels_by_fieldname["range3"], "60-90")
self.assertEqual(labels_by_fieldname["range4"], "90 Above")
# Sales Invoice link for Incoming payments.
invoice_col = next(c for c in columns if c["fieldname"] == "invoice")
self.assertEqual(invoice_col["options"], "Sales Invoice")
def test_invalid_payment_type_party_type_combo_throws(self):
# Incoming + Supplier is invalid.
self.assertRaises(
frappe.ValidationError,
self.run_report,
payment_type="Incoming",
party_type="Supplier",
)
# Outgoing + Customer is invalid.
self.assertRaises(
frappe.ValidationError,
self.run_report,
payment_type="Outgoing",
party_type="Customer",
)

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 2,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:15.898375",
"modified": "2026-07-01 13:36:14.934965",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -207,7 +207,7 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
return chart
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):

View File

@@ -0,0 +1,107 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.profitability_analysis.profitability_analysis import execute
from erpnext.tests.utils import ERPNextTestSuite
INCOME = "Sales - _TC"
EXPENSE = "_Test Account Cost for Goods Sold - _TC"
BANK = "_Test Bank - _TC"
class TestProfitabilityAnalysis(ERPNextTestSuite):
def run_report(self, fiscal_year="_Test Fiscal Year 2026", **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"based_on": "Cost Center",
"fiscal_year": fiscal_year,
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)[1]
def make_cc(self, name, **args):
create_cost_center(cost_center_name=name, **args)
return name + " - _TC"
def row(self, data, account):
return next(r for r in data if r.get("account") == account)
def book_income(self, cost_center, amount, posting_date="2026-06-01"):
create_sales_invoice(
cost_center=cost_center, income_account=INCOME, rate=amount, qty=1, posting_date=posting_date
)
def book_expense(self, cost_center, amount, posting_date="2026-06-01"):
make_journal_entry(
EXPENSE, BANK, amount, cost_center=cost_center, posting_date=posting_date, submit=True
)
def test_income_expense_and_gross_profit(self):
# a dedicated leaf cost center keeps these exact assertions free of GL that
# other tests may book against a shared cost center in the same fiscal year
cc = self.make_cc("_Test PA Income Expense")
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
row = self.row(self.run_report(), cc)
self.assertEqual(row["income"], 10000)
self.assertEqual(row["expense"], 4000)
self.assertEqual(row["gross_profit_loss"], 6000)
def test_parent_cost_center_accumulates_children(self):
parent = self.make_cc("_Test PA Parent", is_group=1)
child_1 = self.make_cc("_Test PA Child 1", parent_cost_center=parent)
child_2 = self.make_cc("_Test PA Child 2", parent_cost_center=parent)
self.book_income(child_1, 10000)
self.book_expense(child_2, 3000)
data = self.run_report()
self.assertEqual(self.row(data, child_1)["income"], 10000)
self.assertEqual(self.row(data, child_2)["expense"], 3000)
parent_row = self.row(data, parent)
self.assertEqual(parent_row["income"], 10000)
self.assertEqual(parent_row["expense"], 3000)
self.assertEqual(parent_row["gross_profit_loss"], 7000)
def test_date_range_excludes_out_of_period_entries(self):
cc = self.make_cc("_Test PA Date Range")
self.book_income(cc, 10000, posting_date="2025-06-01")
# the 2025 income must not appear in a 2026 report (zero-value rows are dropped)
accounts_2026 = {r.get("account") for r in self.run_report()}
self.assertNotIn(cc, accounts_2026)
row_2025 = self.row(
self.run_report(
fiscal_year="_Test Fiscal Year 2025", from_date="2025-01-01", to_date="2025-12-31"
),
cc,
)
self.assertEqual(row_2025["income"], 10000)
def test_total_row_sums_income_and_expense(self):
cc = "_Test Cost Center - _TC"
self.book_income(cc, 10000)
self.book_expense(cc, 4000)
data = self.run_report()
# the report appends a blank separator row and a totals row at the end
total_row = data[-1]
# the report wraps the (possibly translated) "Total" label in single quotes
self.assertEqual(total_row["account"], "'" + frappe._("Total") + "'")
# total is built from direct (non-accumulated) values, so it stays internally consistent
self.assertEqual(total_row["gross_profit_loss"], total_row["income"] - total_row["expense"])
# and it includes this test's bookings
self.assertGreaterEqual(total_row["income"], 10000)
self.assertGreaterEqual(total_row["expense"], 4000)

View File

@@ -0,0 +1,171 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.report.purchase_invoice_trends.purchase_invoice_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
COMPANY = "_Test Company"
SUPPLIER = "_Test Supplier"
ITEM = "_Test Item"
POSTING_DATE = "2026-06-01"
def make_dated_purchase_invoice(qty, rate):
# make_purchase_invoice ignores posting_date unless posting time is explicitly set, so build the
# invoice unsubmitted, pin the posting date, then submit to land it in the intended period bucket.
pi = make_purchase_invoice(
supplier=SUPPLIER, item_code=ITEM, qty=qty, rate=rate, posting_date=POSTING_DATE, do_not_submit=1
)
pi.set_posting_time = 1
pi.posting_date = POSTING_DATE
pi.submit()
return pi
class TestPurchaseInvoiceTrends(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": COMPANY,
"fiscal_year": FISCAL_YEAR,
"period": "Yearly",
"based_on": "Item",
}
)
filters.update(extra)
columns, data = execute(filters)
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
@staticmethod
def _cell(labels, row, label):
return row[labels.index(label)]
def _find_row(self, data, key):
for row in data:
if row and row[0] == key:
return row
return None
def test_yearly_item_qty_and_amount(self):
labels_before, data_before = self.run_report()
before = self._find_row(data_before, ITEM)
qty, rate = 4, 250
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report()
self.assertIn("Item", labels)
self.assertIn("Item Name", labels)
self.assertIn("Currency", labels)
self.assertIn("Total(Qty)", labels)
self.assertIn("Total(Amt)", labels)
# Yearly period bucket uses the fiscal year name as the label prefix
self.assertIn(f"{FISCAL_YEAR} (Qty)", labels)
self.assertIn(f"{FISCAL_YEAR} (Amt)", labels)
row = self._find_row(data, ITEM)
self.assertIsNotNone(row)
before_qty = self._cell(labels_before, before, f"{FISCAL_YEAR} (Qty)") if before else 0
before_amt = self._cell(labels_before, before, f"{FISCAL_YEAR} (Amt)") if before else 0
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, f"{FISCAL_YEAR} (Amt)") - before_amt, qty * rate)
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_monthly_bucket(self):
labels_before, data_before = self.run_report(period="Monthly")
before = self._find_row(data_before, ITEM)
qty, rate = 3, 100
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(period="Monthly")
# posting_date 2026-06-01 -> June bucket
self.assertIn("Jun (Qty)", labels)
self.assertIn("Jun (Amt)", labels)
row = self._find_row(data, ITEM)
before_qty = self._cell(labels_before, before, "Jun (Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Jun (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_quarterly_bucket(self):
labels_before, data_before = self.run_report(period="Quarterly")
before = self._find_row(data_before, ITEM)
qty, rate = 2, 150
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(period="Quarterly")
# 2026-06-01 falls in the Apr-Jun quarter
self.assertIn("Apr-Jun (Qty)", labels)
self.assertIn("Apr-Jun (Amt)", labels)
row = self._find_row(data, ITEM)
before_qty = self._cell(labels_before, before, "Apr-Jun (Qty)") if before else 0
before_amt = self._cell(labels_before, before, "Apr-Jun (Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Apr-Jun (Qty)") - before_qty, qty)
self.assertEqual(self._cell(labels, row, "Apr-Jun (Amt)") - before_amt, qty * rate)
def test_based_on_supplier(self):
labels_before, data_before = self.run_report(based_on="Supplier")
before = self._find_row(data_before, SUPPLIER)
qty, rate = 5, 200
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(based_on="Supplier")
self.assertIn("Supplier", labels)
self.assertIn("Supplier Name", labels)
self.assertIn("Supplier Group", labels)
row = self._find_row(data, SUPPLIER)
self.assertIsNotNone(row)
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)
def test_group_by_item_under_supplier(self):
labels_before, data_before = self.run_report(based_on="Supplier", group_by="Item")
# group_by inserts an "Item" column; the item breakdown row carries the item key there
item_idx = labels_before.index("Item")
before = None
for r in data_before:
if r and r[0] != SUPPLIER and r[item_idx] == ITEM:
before = r
break
qty, rate = 6, 300
make_dated_purchase_invoice(qty, rate)
labels, data = self.run_report(based_on="Supplier", group_by="Item")
self.assertIn("Item", labels)
item_idx = labels.index("Item")
row = None
for r in data:
if r and r[0] != SUPPLIER and r[0] != "'Total'" and r[item_idx] == ITEM:
row = r
break
self.assertIsNotNone(row)
before_tqty = self._cell(labels_before, before, "Total(Qty)") if before else 0
before_tamt = self._cell(labels_before, before, "Total(Amt)") if before else 0
self.assertEqual(self._cell(labels, row, "Total(Qty)") - before_tqty, qty)
self.assertEqual(self._cell(labels, row, "Total(Amt)") - before_tamt, qty * rate)

View File

@@ -0,0 +1,101 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.received_items_to_be_billed.received_items_to_be_billed import execute
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice as make_pi_from_pr
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite
class TestReceivedItemsToBeBilled(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"posting_date": "2026-06-30",
}
)
filters.update(extra)
return execute(filters)[1]
def get_row(self, data, purchase_receipt):
matches = [row for row in data if row.get("name") == purchase_receipt]
return matches[0] if matches else None
def test_unbilled_receipt_appears_with_pending_amount(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
row = self.get_row(self.run_report(), pr.name)
self.assertIsNotNone(row, "Unbilled Purchase Receipt should appear in the report")
self.assertEqual(row.get("supplier"), "_Test Supplier")
self.assertEqual(row.get("item_code"), "_Test Item")
self.assertEqual(row.get("amount"), 1000.0)
self.assertEqual(row.get("billed_amount"), 0.0)
self.assertEqual(row.get("returned_amount"), 0.0)
self.assertEqual(row.get("pending_amount"), 1000.0)
def test_billed_receipt_drops_out_of_report(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
self.assertIsNotNone(self.get_row(self.run_report(), pr.name))
pi = make_pi_from_pr(pr.name)
pi.set_posting_time = 1
pi.posting_date = "2026-06-02"
pi.submit()
self.assertIsNone(
self.get_row(self.run_report(), pr.name),
"Fully billed Purchase Receipt should no longer appear in the report",
)
def test_reference_field_filter_limits_to_single_receipt(self):
first_pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
second_pr = make_purchase_receipt(
item_code="_Test Item",
qty=3,
rate=100,
supplier="_Test Supplier",
posting_date="2026-06-01",
)
data = self.run_report(purchase_receipt=first_pr.name)
self.assertIsNotNone(self.get_row(data, first_pr.name))
self.assertIsNone(self.get_row(data, second_pr.name))
def test_posting_date_cutoff_excludes_later_receipts(self):
pr = make_purchase_receipt(
item_code="_Test Item",
qty=5,
rate=200,
supplier="_Test Supplier",
posting_date="2026-06-15",
)
self.assertIsNone(
self.get_row(self.run_report(posting_date="2026-06-01"), pr.name),
"Receipt dated after the cutoff should be excluded",
)
self.assertIsNotNone(self.get_row(self.run_report(posting_date="2026-06-30"), pr.name))

View File

@@ -0,0 +1,118 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_invoice_trends.sales_invoice_trends import execute
from erpnext.tests.utils import ERPNextTestSuite
FISCAL_YEAR = "_Test Fiscal Year 2026"
POSTING_DATE = "2026-06-01"
class TestSalesInvoiceTrends(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"fiscal_year": FISCAL_YEAR,
"based_on": "Item",
"period": "Yearly",
}
)
filters.update(extra)
columns, data = execute(filters)
labels = [c.split(":")[0] if isinstance(c, str) else c.get("label") for c in columns]
return labels, data
def _cell(self, data, key_label, key_value, col_label, labels):
"""Return the value at column `col_label` for the row whose first-column
value equals `key_value`, or 0 if that row does not exist yet."""
key_idx = labels.index(key_label)
col_idx = labels.index(col_label)
for row in data:
if row[key_idx] == key_value:
return row[col_idx] or 0
return 0
def test_yearly_item_amount_and_total(self):
# Yearly period => a single "<FY> (Qty)"/"(Amt)" bucket, plus Total(Qty)/Total(Amt).
labels, before = self.run_report()
qty_col = f"{FISCAL_YEAR} (Qty)"
amt_col = f"{FISCAL_YEAR} (Amt)"
before_qty = self._cell(before, "Item", "_Test Item", qty_col, labels)
before_amt = self._cell(before, "Item", "_Test Item", amt_col, labels)
before_tot_qty = self._cell(before, "Item", "_Test Item", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(item="_Test Item", qty=4, rate=200, posting_date=POSTING_DATE)
labels, after = self.run_report()
self.assertEqual(self._cell(after, "Item", "_Test Item", qty_col, labels) - before_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", amt_col, labels) - before_amt, 800)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Qty)", labels) - before_tot_qty, 4)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot_amt, 800)
def test_monthly_lands_in_june_bucket(self):
# Monthly period => one bucket per month; a 2026-06-01 invoice hits "Jun (Qty)"/"(Amt)".
labels, before = self.run_report(period="Monthly")
before_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
before_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
before_tot = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(item="_Test Item", qty=3, rate=100, posting_date=POSTING_DATE)
labels, after = self.run_report(period="Monthly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_qty, 3)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_amt, 300)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_tot, 300)
# Nothing should leak into an unrelated month.
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan (Amt)", labels), 0)
def test_quarterly_lands_in_apr_jun_bucket(self):
# Quarterly period over a Jan-Dec fiscal year => Apr-Jun is the 2nd quarter; June lands there.
labels, before = self.run_report(period="Quarterly")
before_qty = self._cell(before, "Item", "_Test Item", "Apr-Jun (Qty)", labels)
before_amt = self._cell(before, "Item", "_Test Item", "Apr-Jun (Amt)", labels)
create_sales_invoice(item="_Test Item", qty=5, rate=50, posting_date=POSTING_DATE)
labels, after = self.run_report(period="Quarterly")
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Qty)", labels) - before_qty, 5)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Apr-Jun (Amt)", labels) - before_amt, 250)
# Jan-Mar quarter must stay untouched.
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jan-Mar (Amt)", labels), 0)
def test_based_on_customer_total(self):
# based_on=Customer => first column is "Customer"; the customer's Total(Amt) reflects the sale.
labels, before = self.run_report(based_on="Customer")
before_tot_qty = self._cell(before, "Customer", "_Test Customer", "Total(Qty)", labels)
before_tot_amt = self._cell(before, "Customer", "_Test Customer", "Total(Amt)", labels)
create_sales_invoice(
customer="_Test Customer", item="_Test Item", qty=2, rate=300, posting_date=POSTING_DATE
)
labels, after = self.run_report(based_on="Customer")
self.assertEqual(
self._cell(after, "Customer", "_Test Customer", "Total(Qty)", labels) - before_tot_qty, 2
)
self.assertEqual(
self._cell(after, "Customer", "_Test Customer", "Total(Amt)", labels) - before_tot_amt, 600
)
def test_group_by_item_under_customer(self):
# based_on=Customer + group_by=Item inserts an "Item" breakdown column before the period
# buckets; the per-item detail row carries the item key and the amount for that customer/item.
labels, before = self.run_report(based_on="Customer", group_by="Item")
# In group_by mode the detail rows key off the group_by column ("Item"), so snapshot by item.
before_amt = self._cell(before, "Item", "_Test Item", "Total(Amt)", labels)
create_sales_invoice(
customer="_Test Customer", item="_Test Item", qty=6, rate=100, posting_date=POSTING_DATE
)
labels, after = self.run_report(based_on="Customer", group_by="Item")
self.assertIn("Item", labels)
self.assertEqual(self._cell(after, "Item", "_Test Item", "Total(Amt)", labels) - before_amt, 600)

View File

@@ -15,8 +15,6 @@ def execute(filters=None):
columns = get_columns(filters)
filters.get("date")
data = []
if not filters.get("shareholder"):
@@ -24,7 +22,7 @@ def execute(filters=None):
else:
share_type, no_of_shares, rate, amount = 1, 2, 3, 4
all_shares = get_all_shares(filters.get("shareholder"))
all_shares = get_all_shares(filters.get("shareholder"), filters.get("date"), filters.get("company"))
for share_entry in all_shares:
row = False
for datum in data:
@@ -63,5 +61,47 @@ def get_columns(filters):
return columns
def get_all_shares(shareholder):
return frappe.get_doc("Shareholder", shareholder).share_balance
def get_all_shares(shareholder, date, company=None):
"""Share movements for the shareholder up to (and including) `date`, signed by direction:
shares received are positive, shares transferred/sold out are negative.
The shareholder and company predicates are pushed into the query so only the
relevant transfers are fetched instead of scanning the whole table."""
share_transfer = frappe.qb.DocType("Share Transfer")
query = (
frappe.qb.from_(share_transfer)
.select(
share_transfer.share_type,
share_transfer.no_of_shares,
share_transfer.rate,
share_transfer.amount,
share_transfer.from_shareholder,
share_transfer.to_shareholder,
)
.where((share_transfer.docstatus == 1) & (share_transfer.date <= date))
.where(
(share_transfer.to_shareholder == shareholder) | (share_transfer.from_shareholder == shareholder)
)
.orderby(share_transfer.date)
)
if company:
query = query.where(share_transfer.company == company)
transfers = query.run(as_dict=True)
shares = []
for transfer in transfers:
if transfer.to_shareholder == shareholder:
shares.append(transfer)
elif transfer.from_shareholder == shareholder:
shares.append(
frappe._dict(
share_type=transfer.share_type,
no_of_shares=-transfer.no_of_shares,
rate=transfer.rate,
amount=-transfer.amount,
)
)
return shares

View File

@@ -0,0 +1,201 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.share_balance.share_balance import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestShareBalanceReport(ERPNextTestSuite):
def setUp(self):
self.share_type = create_share_type("_Test Share Balance Equity")
self.shareholder = create_shareholder("_Test Share Balance Holder", COMPANY)
def test_date_filter_is_mandatory(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"shareholder": self.shareholder}))
def test_no_shareholder_returns_empty_data(self):
# `shareholder` is optional; without it the report yields no rows.
columns, data = execute(frappe._dict({"date": "2026-06-01", "company": COMPANY}))
self.assertEqual(data, [])
self.assertEqual(len(columns), 5)
def test_balance_after_issue(self):
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
row = self.get_row(date="2026-06-05")
self.assertEqual(row[0], self.shareholder)
self.assertEqual(row[1], self.share_type)
self.assertEqual(row[2], 100) # no_of_shares
self.assertEqual(row[3], 10) # average rate
self.assertEqual(row[4], 1000) # amount = 100 * 10
def test_company_filter_scopes_transfers(self):
# the transfer is booked under `_Test Company`
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
# matching company: the holding shows up
self.assertEqual(self.get_row(date="2026-06-05")[2], 100)
# a different company must not surface this shareholder's transfer
other_company_data = execute(
frappe._dict(
{"date": "2026-06-05", "company": "_Test Company 1", "shareholder": self.shareholder}
)
)[1]
self.assertEqual(other_company_data, [])
def test_balance_increases_on_second_issue(self):
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=101,
to_no=200,
no_of_shares=100,
rate=20,
date="2026-06-10",
)
# The report groups by share type, summing shares and amount and
# recomputing the average rate: (1000 + 2000) / 200 = 15.
row = self.get_row(date="2026-06-15")
self.assertEqual(row[2], 200)
self.assertEqual(row[3], 15)
self.assertEqual(row[4], 3000)
def test_balance_reduces_after_transfer_out(self):
other_holder = create_shareholder("_Test Share Balance Holder 2", COMPANY)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Transfer",
from_shareholder=self.shareholder,
to_shareholder=other_holder,
share_type=self.share_type,
from_no=1,
to_no=40,
no_of_shares=40,
rate=10,
date="2026-06-10",
)
row = self.get_row(date="2026-06-15")
self.assertEqual(row[2], 60) # 100 issued - 40 transferred out
self.assertEqual(row[4], 600)
other_row = self.get_row(date="2026-06-15", shareholder=other_holder)
self.assertEqual(other_row[2], 40)
self.assertEqual(other_row[4], 400)
def test_as_on_date_before_issue_shows_no_holding(self):
# the report is as-on `date`: before any share transfer, the shareholder holds nothing
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
data = execute(
frappe._dict({"date": "2026-05-01", "company": COMPANY, "shareholder": self.shareholder})
)[1]
self.assertEqual(data, [])
def test_as_on_date_reflects_holding_up_to_that_date(self):
# two issues on different dates; an as-on date between them sees only the first
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=1,
to_no=100,
no_of_shares=100,
rate=10,
date="2026-06-01",
)
create_share_transfer(
transfer_type="Issue",
to_shareholder=self.shareholder,
share_type=self.share_type,
from_no=101,
to_no=200,
no_of_shares=100,
rate=20,
date="2026-06-10",
)
self.assertEqual(self.get_row(date="2026-06-05")[2], 100) # only the first issue
self.assertEqual(self.get_row(date="2026-06-15")[2], 200) # both issues
def get_row(self, date, shareholder=None):
filters = frappe._dict(
{"date": date, "company": COMPANY, "shareholder": shareholder or self.shareholder}
)
data = execute(filters)[1]
holdings = [r for r in data if r[1] == self.share_type]
self.assertEqual(len(holdings), 1, f"Expected one row for share type, got: {data}")
return holdings[0]
def create_share_type(title):
if not frappe.db.exists("Share Type", title):
frappe.get_doc({"doctype": "Share Type", "title": title}).insert()
return title
def create_shareholder(title, company):
shareholder = frappe.get_doc({"doctype": "Shareholder", "title": title, "company": company}).insert()
return shareholder.name
def create_share_transfer(**kwargs):
kwargs.setdefault("company", COMPANY)
kwargs.setdefault("asset_account", "Cash - _TC")
kwargs.setdefault("equity_or_liability_account", "Creditors - _TC")
transfer = frappe.get_doc({"doctype": "Share Transfer", **kwargs})
transfer.submit()
return transfer

View File

@@ -0,0 +1,171 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.report.share_ledger.share_ledger import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
# The report returns legacy positional columns (no fieldnames); name the indices once
# here so a column reorder needs a single edit instead of silently shifting assertions.
COL_SHAREHOLDER = 0
COL_DATE = 1
COL_TRANSFER_TYPE = 2
COL_SHARE_TYPE = 3
COL_NO_OF_SHARES = 4
COL_RATE = 5
COL_AMOUNT = 6
COL_COMPANY = 7
COL_SHARE_TRANSFER = 8
class TestShareLedger(ERPNextTestSuite):
def setUp(self):
self.shareholder = self.create_shareholder("_Test Share Ledger Holder")
# Issue 100 shares on 2026-06-01, then another 50 on 2026-06-10.
self.first = self.issue_shares(date="2026-06-01", from_no=1, to_no=100, rate=10)
self.second = self.issue_shares(date="2026-06-10", from_no=101, to_no=150, rate=12)
def test_ledger_lists_all_transfers_upto_date(self):
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
self.assertEqual(len(data), 2)
first_row, second_row = data
self.assertEqual(first_row[COL_SHAREHOLDER], self.shareholder)
self.assertEqual(first_row[COL_DATE], frappe.utils.getdate("2026-06-01"))
self.assertEqual(first_row[COL_TRANSFER_TYPE], "Issue")
self.assertEqual(first_row[COL_SHARE_TYPE], "Equity")
self.assertEqual(first_row[COL_NO_OF_SHARES], 100)
self.assertEqual(first_row[COL_RATE], 10)
self.assertEqual(first_row[COL_AMOUNT], 1000)
self.assertEqual(first_row[COL_COMPANY], COMPANY)
self.assertEqual(first_row[COL_SHARE_TRANSFER], self.first)
self.assertEqual(second_row[COL_DATE], frappe.utils.getdate("2026-06-10"))
self.assertEqual(second_row[COL_NO_OF_SHARES], 50)
self.assertEqual(second_row[COL_RATE], 12)
self.assertEqual(second_row[COL_AMOUNT], 600)
self.assertEqual(second_row[COL_SHARE_TRANSFER], self.second)
def test_running_balance_of_shares(self):
data = self.run_report(shareholder=self.shareholder, date="2026-06-30")
# The ledger records each transfer's raw no_of_shares (always positive); it does
# not sign by direction. With only incoming "Issue" rows here, summing them is a
# valid running total. (Directional balances are the Share Balance report's job.)
running = 0
balances = []
for row in data:
running += row[COL_NO_OF_SHARES]
balances.append(running)
self.assertEqual(balances, [100, 150])
def test_as_on_date_between_transfers_shows_only_first(self):
data = self.run_report(shareholder=self.shareholder, date="2026-06-05")
self.assertEqual(len(data), 1)
self.assertEqual(data[0][COL_SHARE_TRANSFER], self.first)
self.assertEqual(data[0][COL_NO_OF_SHARES], 100)
def test_transfer_type_label_when_shareholder_is_seller(self):
buyer = self.create_shareholder("_Test Share Ledger Buyer")
transfer = self.make_transfer(
from_shareholder=self.shareholder,
to_shareholder=buyer,
date="2026-06-15",
from_no=1,
to_no=40,
rate=10,
)
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
# seller side: the label names the counterparty it went "to"
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer to {buyer}")
def test_transfer_type_label_when_shareholder_is_buyer(self):
seller = self.create_shareholder("_Test Share Ledger Seller")
# the seller must own shares before it can transfer them
self.issue_shares(date="2026-06-12", from_no=201, to_no=300, rate=10, shareholder=seller)
transfer = self.make_transfer(
from_shareholder=seller,
to_shareholder=self.shareholder,
date="2026-06-15",
from_no=201,
to_no=240,
rate=10,
)
row = self.transfer_row(self.run_report(shareholder=self.shareholder, date="2026-06-30"), transfer)
# buyer side: the label names the counterparty it came "from"
self.assertEqual(row[COL_TRANSFER_TYPE], f"Transfer from {seller}")
def test_missing_date_throws(self):
self.assertRaises(frappe.ValidationError, execute, frappe._dict(shareholder=self.shareholder))
def test_missing_shareholder_returns_no_rows(self):
data = self.run_report(date="2026-06-30")
self.assertEqual(data, [])
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, **extra})
return execute(filters)[1]
def transfer_row(self, data, transfer_name):
row = next((r for r in data if r[COL_SHARE_TRANSFER] == transfer_name), None)
self.assertIsNotNone(row, f"Share Transfer {transfer_name} missing from ledger")
return row
def create_shareholder(self, title):
doc = frappe.get_doc(
{
"doctype": "Shareholder",
"title": title,
"company": COMPANY,
}
).insert()
return doc.name
def issue_shares(self, date, from_no, to_no, rate, shareholder=None):
doc = frappe.get_doc(
{
"doctype": "Share Transfer",
"transfer_type": "Issue",
"date": date,
"to_shareholder": shareholder or self.shareholder,
"share_type": "Equity",
"from_no": from_no,
"to_no": to_no,
"no_of_shares": to_no - from_no + 1,
"rate": rate,
"company": COMPANY,
"asset_account": "Cash - _TC",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.submit()
return doc.name
def make_transfer(self, from_shareholder, to_shareholder, date, from_no, to_no, rate):
doc = frappe.get_doc(
{
"doctype": "Share Transfer",
"transfer_type": "Transfer",
"date": date,
"from_shareholder": from_shareholder,
"to_shareholder": to_shareholder,
"share_type": "Equity",
"from_no": from_no,
"to_no": to_no,
"no_of_shares": to_no - from_no + 1,
"rate": rate,
"company": COMPANY,
"asset_account": "Cash - _TC",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.submit()
return doc.name

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 4,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:42.740436",
"modified": "2026-07-01 17:32:21.801141",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
@@ -37,6 +37,6 @@
"role": "Auditor"
}
],
"synced_report": 0,
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -583,7 +583,7 @@ def hide_group_accounts(data):
return non_group_accounts_data
def execute_synced_report(filters):
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):

View File

@@ -0,0 +1,63 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.report.voucher_wise_balance.voucher_wise_balance import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestVoucherWiseBalance(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, voucher_no):
for row in data:
if row.get("voucher_no") == voucher_no:
return row
return None
def test_balanced_voucher_not_flagged(self):
jv = make_journal_entry(
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
)
data = self.run_report()
self.assertIsNone(
self.find_row(data, jv.name),
msg="A balanced voucher (debit == credit) must not be flagged.",
)
def test_imbalanced_voucher_flagged(self):
jv = make_journal_entry(
"Sales - _TC", "_Test Bank - _TC", 1000, submit=True, posting_date="2026-06-01"
)
# Tamper one GL Entry: drop the debit side so debit != credit for this voucher.
gle_name = frappe.db.get_value(
"GL Entry",
{"voucher_no": jv.name, "is_cancelled": 0, "debit": [">", 0]},
"name",
)
self.assertIsNotNone(gle_name, msg="Expected a debit GL Entry for the journal entry.")
frappe.db.set_value("GL Entry", gle_name, {"debit": 400, "debit_in_account_currency": 400})
data = self.run_report()
row = self.find_row(data, jv.name)
self.assertIsNotNone(row, msg="An imbalanced voucher must be flagged by the report.")
self.assertEqual(row.get("voucher_type"), "Journal Entry")
self.assertEqual(row.get("credit"), 1000)
self.assertEqual(row.get("debit"), 400)
self.assertNotEqual(
row.get("debit"), row.get("credit"), msg="Flagged rows must have debit != credit."
)

View File

@@ -0,0 +1,329 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-14 12:44:31.994274",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "database",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Accounts Setup",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.138704",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Accounts Setup",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 55.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 0,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Chart of Cost Centers",
"link_to": "Cost Center",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Account Category",
"link_to": "Account Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",
"link_to": "Accounting Dimension",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency",
"link_to": "Currency",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange",
"link_to": "Currency Exchange",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Finance Book",
"link_to": "Finance Book",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Mode of Payment",
"link_to": "Mode of Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Term",
"link_to": "Payment Term",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry Template",
"link_to": "Journal Entry Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Terms and Conditions",
"link_to": "Terms and Conditions",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Company",
"link_to": "Company",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Fiscal Year",
"link_to": "Fiscal Year",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Taxes",
"link_to": "Sales Taxes and Charges Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "lock-keyhole-open",
"indent": 1,
"keep_closed": 0,
"label": "Opening & Closing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "COA Importer",
"link_to": "Chart of Accounts Importer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Opening Invoice Tool",
"link_to": "Opening Invoice Creation Tool",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Period",
"link_to": "Accounting Period",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "FX Revaluation",
"link_to": "Exchange Rate Revaluation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Period Closing Voucher",
"link_to": "Period Closing Voucher",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 1,
"keep_closed": 0,
"label": "Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Accounts Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Currency Exchange Settings",
"link_to": "Currency Exchange Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Accounts Setup",
"type": "Workspace"
}

View File

@@ -0,0 +1,222 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:22.767176",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "circle-dollar-sign",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Banking",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.924019",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Banking",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 49.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "book-open-check",
"indent": 0,
"keep_closed": 0,
"label": "Bank Clearance",
"link_to": "Bank Clearance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "tool",
"indent": 0,
"keep_closed": 0,
"label": "Bank Reconciliation",
"link_to": "Bank Reconciliation Tool",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "clipboard-check",
"indent": 0,
"keep_closed": 0,
"label": "Reconciliation Statement",
"link_to": "Bank Reconciliation Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "split",
"indent": 0,
"keep_closed": 0,
"label": "Unreconcile Payment",
"link_to": "Unreconcile Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "link",
"indent": 0,
"keep_closed": 0,
"label": "Process Payment Reconciliation",
"link_to": "Process Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Bank",
"link_to": "Bank",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Bank Account",
"link_to": "Bank Account",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Bank Account Type",
"link_to": "Bank Account Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Bank Account Subtype",
"link_to": "Bank Account Subtype",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Bank Guarantee",
"link_to": "Bank Guarantee",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Plaid Settings",
"link_to": "Plaid Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "scroll-text",
"indent": 1,
"keep_closed": 1,
"label": "Dunning",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Dunning",
"link_to": "Dunning",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Dunning Type",
"link_to": "Dunning Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Banking",
"type": "Workspace"
}

View File

@@ -0,0 +1,104 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-14 14:38:20.315394",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Budgeting",
"link_type": "DocType",
"links": [],
"modified": "2026-07-02 04:24:48.116724",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budgeting",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 57.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "briefcase-business",
"indent": 0,
"keep_closed": 0,
"label": "Budget",
"link_to": "Budget",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "badge-cent",
"indent": 0,
"keep_closed": 0,
"label": "Cost Center",
"link_to": "Cost Center",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "accounting",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",
"link_to": "Accounting Dimension",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "notepad-text",
"indent": 0,
"keep_closed": 0,
"label": "Cost Center Allocation",
"link_to": "Cost Center Allocation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "sheet",
"indent": 0,
"keep_closed": 0,
"label": "Budget Variance",
"link_to": "Budget Variance Report",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Budgeting",
"type": "Workspace"
}

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "table",
"icon": "sheet",
"idx": 1,
"indicator_color": "",
"is_hidden": 0,
@@ -266,9 +266,10 @@
"type": "Link"
}
],
"modified": "2026-05-18 09:49:45.138296",
"modified": "2026-06-14 13:44:08.095321",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Financial Reports",
"number_cards": [],
"owner": "Administrator",
@@ -279,6 +280,417 @@
"roles": [],
"sequence_id": 5.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "accounting",
"indent": 1,
"keep_closed": 0,
"label": "Financial Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Balance Sheet",
"link_to": "Balance Sheet",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Profit and Loss",
"link_to": "Profit and Loss Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Cash Flow",
"link_to": "Cash Flow",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance",
"link_to": "Trial Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Consolidated Report",
"link_to": "Consolidated Financial Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Custom Financial Statement",
"link_to": "Custom Financial Statement",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Financial Report Template",
"link_to": "Financial Report Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "book-text",
"indent": 1,
"keep_closed": 0,
"label": "Ledgers",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "General Ledger",
"link_to": "General Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Ledger",
"link_to": "Customer Ledger Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Ledger",
"link_to": "Supplier Ledger Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 1,
"keep_closed": 1,
"label": "Registers",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Receivable",
"link_to": "Accounts Receivable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Payable",
"link_to": "Accounts Payable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "AR Summary",
"link_to": "Accounts Receivable Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "AP Summary",
"link_to": "Accounts Payable Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Register",
"link_to": "Sales Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Register",
"link_to": "Purchase Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise sales Register",
"link_to": "Item-wise Sales Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise Purchase Register",
"link_to": "Item-wise Purchase Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "dollar-sign",
"indent": 1,
"keep_closed": 1,
"label": "Profitability",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Gross Profit",
"link_to": "Gross Profit",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Profitability Analysis",
"link_to": "Profitability Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Invoice Trends",
"link_to": "Sales Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice Trends",
"link_to": "Purchase Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "scroll-text",
"indent": 1,
"keep_closed": 1,
"label": "Other Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance for Party",
"link_to": "Trial Balance for Party",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Period Based On Invoice Date",
"link_to": "Payment Period Based On Invoice Date",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Partners Commission",
"link_to": "Sales Partners Commission",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Credit Balance",
"link_to": "Customer Credit Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Payment Summary",
"link_to": "Sales Payment Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Address And Contacts",
"link_to": "Address And Contacts",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "UAE VAT 201",
"link_to": "UAE VAT 201",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Financial Reports",
"type": "Workspace"
}

View File

@@ -587,9 +587,10 @@
"type": "Link"
}
],
"modified": "2026-01-23 11:05:47.246213",
"modified": "2026-06-14 13:44:08.471142",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Invoicing",
"number_cards": [
{
@@ -617,6 +618,354 @@
"roles": [],
"sequence_id": 2.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Invoicing",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Accounts",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
"label": "Chart of Accounts",
"link_to": "Account",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "arrow-left-to-line",
"indent": 1,
"keep_closed": 0,
"label": "Receivables",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Invoice",
"link_to": "Sales Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Credit Note",
"link_to": "Sales Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"route_options": "{\"is_return\": 1}",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Receivable",
"link_to": "Accounts Receivable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "arrow-right-from-line",
"indent": 1,
"keep_closed": 0,
"label": "Payables",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Supplier",
"link_to": "Supplier",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice",
"link_to": "Purchase Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Debit Note",
"link_to": "Purchase Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"route_options": "{\"is_return\": 1}",
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Payable",
"link_to": "Accounts Payable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 0,
"label": "Payments",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Entry",
"link_to": "Payment Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry",
"link_to": "Journal Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Request",
"link_to": "Payment Request",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Order",
"link_to": "Payment Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Reconciliation",
"link_to": "Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Unreconcile Payment",
"link_to": "Unreconcile Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Process Payment Reconciliation",
"link_to": "Process Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Accounting Ledger",
"link_to": "Repost Accounting Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Payment Ledger",
"link_to": "Repost Payment Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 0,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "General Ledger",
"link_to": "General Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance",
"link_to": "Trial Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Financial Reports",
"link_to": "Financial Reports",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Invoicing",
"type": "Workspace"
}

View File

@@ -0,0 +1,240 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:21.886461",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "receipt-text",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Payments",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.184761",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Payments",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 47.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Payments",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 0,
"label": "Payments",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Entry",
"link_to": "Payment Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Journal Entry",
"link_to": "Journal Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Request",
"link_to": "Payment Request",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Order",
"link_to": "Payment Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Payment Reconciliation",
"link_to": "Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Unreconcile Payment",
"link_to": "Unreconcile Payment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Process Payment Reconciliation",
"link_to": "Process Payment Reconciliation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Accounting Ledger",
"link_to": "Repost Accounting Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Repost Payment Ledger",
"link_to": "Repost Payment Ledger",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Receivable",
"link_to": "Accounts Receivable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Accounts Payable",
"link_to": "Accounts Payable",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "General Ledger",
"link_to": "General Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Trial Balance",
"link_to": "Trial Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Financial Reports",
"link_to": "Financial Reports",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Payments",
"type": "Workspace"
}

View File

@@ -0,0 +1,86 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:22.831729",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "money-coins-1",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Share Management",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:51.040978",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Share Management",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 50.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 1,
"collapsible": 1,
"icon": "customer",
"indent": 0,
"keep_closed": 0,
"label": "Shareholder",
"link_to": "Shareholder",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "move-horizontal",
"indent": 0,
"keep_closed": 0,
"label": "Share Transfer",
"link_to": "Share Transfer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "list",
"indent": 0,
"keep_closed": 0,
"label": "Share Ledger",
"link_to": "Share Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "notepad-text",
"indent": 0,
"keep_closed": 0,
"label": "Share Balance",
"link_to": "Share Balance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Share Management",
"type": "Workspace"
}

View File

@@ -0,0 +1,121 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-14 14:08:36.817393",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Subscriptions",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 14:08:36.999272",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscriptions",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 56.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "circle-dollar-sign",
"indent": 0,
"keep_closed": 0,
"label": "Subscription",
"link_to": "Subscription",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "receipt-text",
"indent": 0,
"keep_closed": 0,
"label": "Subscription Plan",
"link_to": "Subscription Plan",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Subscription Settings",
"link_to": "Subscription Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier",
"link_to": "Supplier",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Subscriptions",
"type": "Workspace"
}

View File

@@ -0,0 +1,188 @@
{
"app": "erpnext",
"charts": [],
"content": "[]",
"creation": "2026-06-11 11:51:22.649582",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "money-coins-1",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Taxes",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.894825",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
"name": "Taxes",
"number_cards": [],
"owner": "Administrator",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 48.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "panel-bottom-close",
"indent": 0,
"keep_closed": 0,
"label": "Sales Tax Template",
"link_to": "Sales Taxes and Charges Template",
"link_type": "DocType",
"navigate_to_tab": "",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "panel-top-close",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Tax Template",
"link_to": "Purchase Taxes and Charges Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"indent": 0,
"keep_closed": 0,
"label": "Item Tax Template",
"link_to": "Item Tax Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "triangle",
"indent": 0,
"keep_closed": 0,
"label": "Tax Category",
"link_to": "Tax Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "book-open-text",
"indent": 0,
"keep_closed": 0,
"label": "Tax Rule",
"link_to": "Tax Rule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "book-text",
"indent": 0,
"keep_closed": 0,
"label": "Tax Withholding Category",
"link_to": "Tax Withholding Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Tax Withholding Group",
"link_to": "Tax Withholding Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "notebook-text",
"indent": 0,
"keep_closed": 0,
"label": "Deduction Certificate",
"link_to": "Lower Deduction Certificate",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_to": "",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "TDS Computation Summary",
"link_to": "TDS Computation Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Tax Withholding Details",
"link_to": "Tax Withholding Details",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Taxes",
"type": "Workspace"
}

View File

@@ -199,9 +199,10 @@
"type": "Link"
}
],
"modified": "2025-12-31 16:22:38.132729",
"modified": "2026-06-14 13:44:08.417956",
"modified_by": "Administrator",
"module": "Assets",
"module_onboarding": "Asset Onboarding",
"name": "Assets",
"number_cards": [],
"owner": "Administrator",
@@ -212,6 +213,294 @@
"roles": [],
"sequence_id": 7.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Assets",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Asset",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "laptop",
"indent": 0,
"keep_closed": 0,
"label": "Asset",
"link_to": "Asset",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "trending-down",
"indent": 0,
"keep_closed": 0,
"label": "Depreciation Schedule",
"link_to": "Asset Depreciation Schedule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sprout",
"indent": 0,
"keep_closed": 0,
"label": "Asset Capitalization",
"link_to": "Asset Capitalization",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "move-horizontal",
"indent": 0,
"keep_closed": 0,
"label": "Asset Movement",
"link_to": "Asset Movement",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance Team",
"link_to": "Asset Maintenance Team",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance",
"link_to": "Asset Maintenance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance Log",
"link_to": "Asset Maintenance Log",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Value Adjustment",
"link_to": "Asset Value Adjustment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Repair",
"link_to": "Asset Repair",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Fixed Asset Register",
"link_to": "Fixed Asset Register",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Depreciation Ledger",
"link_to": "Asset Depreciation Ledger",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Depreciations and Balances",
"link_to": "Asset Depreciations and Balances",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Maintenance",
"link_to": "Asset Maintenance",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Activity",
"link_to": "Asset Activity",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Asset Category",
"link_to": "Asset Category",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Location",
"link_to": "Location",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Accounts Settings",
"link_type": "DocType",
"navigate_to_tab": "assets_tab",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link",
"url": ""
}
],
"standard": 1,
"title": "Assets",
"type": "Workspace"
}

View File

@@ -0,0 +1,130 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
)
from erpnext.buying.report.item_wise_purchase_history.item_wise_purchase_history import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestItemWisePurchaseHistory(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": "2026-01-01",
"to_date": "2026-12-31",
**extra,
}
)
return execute(filters)
def po_row(self, po_name, **extra):
data = self.run_report(**extra)[1]
return next(row for row in data if row["purchase_order"] == po_name)
def test_purchase_order_line_shown_with_values(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
row = self.po_row(po.name)
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["quantity"], 10)
self.assertEqual(row["rate"], 500)
self.assertEqual(row["amount"], 5000)
self.assertEqual(row["supplier"], "_Test Supplier")
def test_draft_purchase_order_excluded(self):
po = create_purchase_order(transaction_date="2026-06-01", do_not_submit=True)
names = {row["purchase_order"] for row in self.run_report()[1]}
self.assertNotIn(po.name, names)
def test_date_range_filters_on_transaction_date(self):
po = create_purchase_order(transaction_date="2026-06-01")
in_range = {
row["purchase_order"] for row in self.run_report(from_date="2026-05-01", to_date="2026-07-01")[1]
}
self.assertIn(po.name, in_range)
out_of_range = {
row["purchase_order"] for row in self.run_report(from_date="2026-01-01", to_date="2026-03-01")[1]
}
self.assertNotIn(po.name, out_of_range)
def test_item_code_filter(self):
po = create_purchase_order(
transaction_date="2026-06-01",
rm_items=[
{"item_code": "_Test Item", "qty": 5, "rate": 500, "warehouse": "_Test Warehouse - _TC"},
{"item_code": "_Test Item 2", "qty": 3, "rate": 200, "warehouse": "_Test Warehouse - _TC"},
],
)
rows = self.run_report(item_code="_Test Item 2")[1]
self.assertEqual({row["item_code"] for row in rows}, {"_Test Item 2"})
# the filtered-out line of the same order must not leak in
self.assertTrue(all(row["purchase_order"] == po.name for row in rows))
def test_item_group_filter(self):
# _Test Item is in _Test Item Group; _Test FG Item is in _Test Item Group Desktops
po_test_group = create_purchase_order(item_code="_Test Item", transaction_date="2026-06-01")
po_other_group = create_purchase_order(item_code="_Test FG Item", transaction_date="2026-06-01")
names = {row["purchase_order"] for row in self.run_report(item_group="_Test Item Group")[1]}
self.assertIn(po_test_group.name, names)
self.assertNotIn(po_other_group.name, names)
def test_supplier_filter(self):
create_purchase_order(supplier="_Test Supplier", transaction_date="2026-06-01")
create_purchase_order(supplier="_Test Supplier 1", transaction_date="2026-06-01")
suppliers = {row["supplier"] for row in self.run_report(supplier="_Test Supplier")[1]}
self.assertEqual(suppliers, {"_Test Supplier"})
def test_received_quantity_reflects_receipt(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
create_pr_against_po(po.name, received_qty=4)
self.assertEqual(self.po_row(po.name)["received_qty"], 4)
def test_billed_amount_reflects_invoice(self):
po = create_purchase_order(qty=10, rate=500, transaction_date="2026-06-01")
pi = make_purchase_invoice(po.name)
pi.insert()
pi.submit()
self.assertEqual(self.po_row(po.name)["billed_amt"], 5000)
def test_amounts_reported_in_company_currency(self):
# a USD order must report rate/amount converted to the company's currency (base_* fields)
po = create_purchase_order(
do_not_save=True,
currency="USD",
qty=10,
rate=100,
transaction_date="2026-06-01",
)
po.conversion_rate = 80
po.insert()
po.submit()
row = self.po_row(po.name)
self.assertEqual(row["rate"], 8000) # 100 USD * 80
self.assertEqual(row["amount"], 80000) # 10 * 100 USD * 80
def test_chart_aggregates_amount_per_item(self):
create_purchase_order(item_code="_Test Item", qty=2, rate=500, transaction_date="2026-06-01")
create_purchase_order(item_code="_Test Item", qty=3, rate=500, transaction_date="2026-06-01")
chart = self.run_report(item_code="_Test Item")[3]
labels = chart["data"]["labels"]
values = chart["data"]["datasets"][0]["values"]
self.assertIn("_Test Item", labels)
# 2*500 + 3*500 aggregated for the item
self.assertEqual(values[labels.index("_Test Item")], 2500)

View File

@@ -0,0 +1,93 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
SUPPLIER = "_Test Supplier"
SUPPLIER_GROUP = "_Test Supplier Group"
# A historical window that ordinary test fixtures don't post into.
FROM_DATE = "2019-04-01"
TO_DATE = "2019-06-30"
class TestPurchaseAnalytics(ERPNextTestSuite):
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
def setUp(self):
frappe.set_user("Administrator")
def _filters(self, **overrides):
filters = {
"doc_type": "Purchase Order",
"value_quantity": "Value",
"range": "Monthly",
"company": COMPANY,
"from_date": FROM_DATE,
"to_date": TO_DATE,
}
filters.update(overrides)
return frappe._dict(filters)
def _rows(self, filters):
return {row["entity"]: row for row in execute(filters)[1]}
def make_po(self, qty=4, rate=250):
return create_purchase_order(
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
)
def test_supplier_group_tree_rolls_up_to_root(self):
filters = self._filters(tree_type="Supplier Group")
base = self._rows(filters)
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
po = self.make_po(qty=4, rate=250)
rows = self._rows(filters)
# supplier is remapped to its group; the root sits at indent 0
self.assertIn(SUPPLIER_GROUP, rows)
self.assertIn("All Supplier Groups", rows)
self.assertNotIn(SUPPLIER, rows)
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
def test_item_group_tree_rolls_up_to_root(self):
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
filters = self._filters(tree_type="Item Group")
base = self._rows(filters)
base_group = flt(base.get(item_group, {}).get("total", 0.0))
po = self.make_po(qty=4, rate=250)
rows = self._rows(filters)
self.assertIn(item_group, rows)
self.assertIn("All Item Groups", rows)
# the raw item code must not leak as its own entity; the root sits at indent 0
self.assertNotIn("_Test Item", rows)
self.assertEqual(rows["All Item Groups"]["indent"], 0)
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
def test_supplier_group_by_quantity(self):
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
base = self._rows(filters)
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
po = self.make_po(qty=7, rate=100)
rows = self._rows(filters)
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
# the quantity must roll up to the root too, not just the leaf group
self.assertAlmostEqual(
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
)

View File

@@ -0,0 +1,49 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.buying.report.subcontract_order_summary.subcontract_order_summary import execute
from erpnext.controllers.tests.test_subcontracting_controller import (
get_subcontracting_order,
make_bom_for_subcontracted_items,
make_raw_materials,
make_service_items,
make_subcontracted_items,
)
from erpnext.tests.utils import ERPNextTestSuite
FG_ITEM = "Subcontracted Item SA7"
class TestSubcontractOrderSummary(ERPNextTestSuite):
"""The report lists Subcontracting Order finished items with their ordered and
received quantities within the transaction-date window."""
def setUp(self):
make_subcontracted_items()
make_raw_materials()
make_service_items()
make_bom_for_subcontracted_items()
def run_report(self, **extra):
filters = frappe._dict(
{"company": "_Test Company", "from_date": add_days(today(), -1), "to_date": add_days(today(), 1)}
)
filters.update(extra)
return execute(filters)[1]
def test_subcontracting_order_is_listed(self):
sco = get_subcontracting_order()
rows = [r for r in self.run_report(name=sco.name) if r.get("item_code") == FG_ITEM]
self.assertTrue(rows, "Subcontracting Order finished item missing from report")
self.assertEqual(rows[0]["qty"], 10)
self.assertEqual(rows[0]["received_qty"], 0) # nothing received yet
def test_out_of_range_date_excludes_order(self):
sco = get_subcontracting_order()
data = self.run_report(name=sco.name, from_date="2019-01-01", to_date="2019-01-31")
self.assertEqual(data, [])

View File

@@ -0,0 +1,66 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.buying.report.supplier_quotation_comparison.supplier_quotation_comparison import execute
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
ITEM = "_Test Item"
class TestSupplierQuotationComparison(ERPNextTestSuite):
"""The report lists Supplier Quotation item lines so quotes for the same item can
be compared across suppliers."""
def make_quotation(self, supplier, qty, rate, uom=None):
item = {"item_code": ITEM, "qty": qty, "rate": rate, "warehouse": "_Test Warehouse - _TC"}
if uom:
item["uom"] = uom
sq = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": supplier,
"company": COMPANY,
"currency": "INR",
"transaction_date": "2026-06-01",
"items": [item],
}
)
sq.insert()
sq.submit()
return sq
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, "from_date": "2026-01-01", "to_date": "2026-12-31"})
filters.update(extra)
return execute(filters)[1]
def test_no_filters_returns_empty(self):
self.assertEqual(execute(None)[1], [])
def test_quotation_line_listed_with_price(self):
# _Test UOM 1 converts at 10 stock units per qty, so price_per_unit
# (amount / stock_qty) diverges from base_rate and the division path is tested
sq = self.make_quotation("_Test Supplier", qty=10, rate=100, uom="_Test UOM 1")
rows = [r for r in self.run_report(item_code=ITEM) if r.get("quotation") == sq.name]
self.assertTrue(rows, "Supplier Quotation line missing from report")
row = rows[0]
self.assertEqual(row["supplier_name"], "_Test Supplier")
self.assertEqual(row["qty"], 10)
self.assertEqual(row["base_rate"], 100)
self.assertEqual(row["base_amount"], 1000)
# 1000 amount / (10 qty * 10 conversion) = 10, distinct from the 100 base_rate
self.assertEqual(row["price_per_unit"], 10)
def test_compares_multiple_suppliers_for_item(self):
sq1 = self.make_quotation("_Test Supplier", qty=10, rate=100)
sq2 = self.make_quotation("_Test Supplier 1", qty=10, rate=120)
quotes = {r["quotation"]: r for r in self.run_report(item_code=ITEM)}
self.assertIn(sq1.name, quotes)
self.assertIn(sq2.name, quotes)
self.assertEqual(quotes[sq1.name]["base_rate"], 100)
self.assertEqual(quotes[sq2.name]["base_rate"], 120)

View File

@@ -341,17 +341,6 @@
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 1,
"label": "Item Wise Consumption",
"link_count": 0,
"link_to": "Item Wise Consumption",
"link_type": "Report",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
@@ -512,9 +501,10 @@
"type": "Link"
}
],
"modified": "2026-01-02 14:55:59.078773",
"modified": "2026-06-14 13:43:50.509039",
"modified_by": "Administrator",
"module": "Buying",
"module_onboarding": "Buying Onboarding",
"name": "Buying",
"number_cards": [
{
@@ -538,6 +528,403 @@
"roles": [],
"sequence_id": 5.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Buying",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Buying",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "notepad-text",
"indent": 0,
"keep_closed": 0,
"label": "Material Request",
"link_to": "Material Request",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "git-pull-request-arrow",
"indent": 0,
"keep_closed": 0,
"label": "Request for Quotation",
"link_to": "Request for Quotation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "book-open-text",
"indent": 0,
"keep_closed": 0,
"label": "Supplier Quotation",
"link_to": "Supplier Quotation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "receipt-text",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Order",
"link_to": "Purchase Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "liabilities",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice",
"link_to": "Purchase Invoice",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Supplier",
"link_to": "Supplier",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Group",
"link_to": "Supplier Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Price List",
"link_to": "Price List",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Address",
"link_to": "Address",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Contacts",
"link_to": "Contact",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard",
"link_to": "Supplier Scorecard",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard Criteria",
"link_to": "Supplier Scorecard Criteria",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard Variable",
"link_to": "Supplier Scorecard Variable",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Scorecard Standing",
"link_to": "Supplier Scorecard Standing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Analytics",
"link_to": "Purchase Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Order Analysis",
"link_to": "Purchase Order Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Requested Items to Order and Receive",
"link_to": "Requested Items to Order and Receive",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Items To Be Requested",
"link_to": "Items To Be Requested",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item-wise Purchase History",
"link_to": "Item-wise Purchase History",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Receipt Trends ",
"link_to": "Purchase Receipt Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice Trends",
"link_to": "Purchase Invoice Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Purchase Order Trends",
"link_to": "Purchase Order Trends",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Procurement Tracker",
"link_to": "Procurement Tracker",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Item Wise Consumption",
"link_to": "Item Wise Consumption",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Quotation Comparison",
"link_to": "Supplier Quotation Comparison",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Supplier Addresses And Contacts",
"link_to": "Address And Contacts",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Buying Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Buying",
"type": "Workspace"
}

View File

@@ -167,7 +167,8 @@ status_map = {
"Pick List": [
["Draft", None],
["Open", "eval:self.docstatus == 1"],
["Completed", "stock_entry_exists"],
["Completed", "is_fully_transferred"],
["Partially Transferred", "is_partially_transferred"],
[
"Partly Delivered",
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",

View File

@@ -0,0 +1,71 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.crm.report.lead_owner_efficiency.lead_owner_efficiency import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestLeadOwnerEfficiency(ERPNextTestSuite):
"""Groups leads by their owner and counts the opportunity/quotation/order funnel
derived from those leads."""
def setUp(self):
# a unique owner keeps the per-owner counts isolated from other tests' leads
self.owner = self.make_user()
def make_user(self):
email = f"lead_owner_{frappe.generate_hash(length=8)}@example.com"
frappe.get_doc(
{"doctype": "User", "email": email, "first_name": "Lead Owner", "send_welcome_email": 0}
).insert()
return email
def make_lead(self):
return frappe.get_doc(
{
"doctype": "Lead",
"lead_name": f"Lead {frappe.generate_hash(length=6)}",
"lead_owner": self.owner,
"company": "_Test Company",
}
).insert()
def run_report(self, **extra):
filters = frappe._dict({"from_date": add_days(today(), -1), "to_date": today()})
filters.update(extra)
return execute(filters)[1]
def owner_row(self, data):
return next((r for r in data if r["lead_owner"] == self.owner), None)
def test_lead_count_grouped_by_owner(self):
self.make_lead()
self.make_lead()
row = self.owner_row(self.run_report())
self.assertIsNotNone(row, "Lead owner missing from report")
self.assertEqual(row["lead_count"], 2)
self.assertEqual(row["opp_count"], 0)
self.assertEqual(row["opp_lead"], 0.0)
def test_opportunity_from_lead_is_counted(self):
lead = self.make_lead()
frappe.get_doc(
{
"doctype": "Opportunity",
"opportunity_from": "Lead",
"party_name": lead.name,
"company": "_Test Company",
"currency": "INR",
}
).insert()
row = self.owner_row(self.run_report())
self.assertIsNotNone(row, "Lead owner missing from report")
self.assertEqual(row["lead_count"], 1)
self.assertEqual(row["opp_count"], 1)
# one opportunity from one lead -> 100% opp/lead conversion
self.assertEqual(row["opp_lead"], 100.0)

View File

@@ -2,11 +2,11 @@
"app": "erpnext",
"charts": [
{
"chart_name": "Won Opportunities",
"label": "Won Opportunities"
"chart_name": "Territory Wise Sales",
"label": "Territory Wise Sales"
}
],
"content": "[{\"id\":\"4jhDsfZ7EP\",\"type\":\"header\",\"data\":{\"text\":\"This module is scheduled for deprecation and will be completely removed in version 17, please use <a href=\\\"https://frappe.io/crm\\\">Frappe CRM</a> instead.\",\"col\":12}},{\"id\":\"-bzBQ_IbL9\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Won Opportunities\",\"col\":12}},{\"id\":\"LdM1QgUnqU\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"New Lead (Last 1 Month)\",\"col\":4}},{\"id\":\"X23-SXBcYG\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"New Opportunity (Last 1 Month)\",\"col\":4}},{\"id\":\"3rm7fH52M-\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Won Opportunity (Last 1 Month)\",\"col\":4}},{\"id\":\"K6a2Kh5Zav\",\"type\":\"spacer\",\"data\":{\"col\":12}}]",
"content": "[{\"id\":\"4jhDsfZ7EP\",\"type\":\"header\",\"data\":{\"text\":\"This module is scheduled for deprecation and will be completely removed in version 17, please use <a href=\\\"http://frappe.io/crm\\\">Frappe CRM</a> instead.\",\"col\":12}},{\"id\":\"Cj2TyhgiWy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Territory Wise Sales\",\"col\":12}},{\"id\":\"LAKRmpYMRA\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"69RN0XsiJK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead\",\"col\":3}},{\"id\":\"t6PQ0vY-Iw\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Opportunity\",\"col\":3}},{\"id\":\"VOFE0hqXRD\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customer\",\"col\":3}},{\"id\":\"0ik53fuemG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Analytics\",\"col\":3}},{\"id\":\"wdROEmB_XG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"-I9HhcgUKE\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"ttpROKW9vk\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports &amp; Masters</b></span>\",\"col\":12}},{\"id\":\"-76QPdbBHy\",\"type\":\"card\",\"data\":{\"card_name\":\"Sales Pipeline\",\"col\":4}},{\"id\":\"_YmGwzVWRr\",\"type\":\"card\",\"data\":{\"card_name\":\"Masters\",\"col\":4}},{\"id\":\"Bma1PxoXk3\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"80viA0R83a\",\"type\":\"card\",\"data\":{\"card_name\":\"Campaign\",\"col\":4}},{\"id\":\"Buo5HtKRFN\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"sLS_x4FMK2\",\"type\":\"card\",\"data\":{\"card_name\":\"Maintenance\",\"col\":4}}]",
"creation": "2020-01-23 14:48:30.183272",
"custom_blocks": [],
"docstatus": 0,
@@ -18,6 +18,14 @@
"is_hidden": 0,
"label": "CRM",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Reports",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "Lead",
"hidden": 0,
@@ -115,6 +123,14 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Maintenance",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
@@ -148,6 +164,183 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Sales Pipeline",
"link_count": 7,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Lead",
"link_count": 0,
"link_to": "Lead",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Opportunity",
"link_count": 0,
"link_to": "Opportunity",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Customer",
"link_count": 0,
"link_to": "Customer",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Contract",
"link_count": 0,
"link_to": "Contract",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Appointment",
"link_count": 0,
"link_to": "Appointment",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
"link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Communication",
"link_count": 0,
"link_to": "Communication",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Settings",
"link_count": 2,
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "CRM Settings",
"link_count": 0,
"link_to": "CRM Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Settings",
"link_count": 0,
"link_to": "SMS Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Campaign",
"link_count": 5,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Campaign",
"link_count": 0,
"link_to": "Campaign",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Email Campaign",
"link_count": 0,
"link_to": "Email Campaign",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Center",
"link_count": 0,
"link_to": "SMS Center",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Log",
"link_count": 0,
"link_to": "SMS Log",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
"link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@@ -228,24 +421,11 @@
"type": "Link"
}
],
"modified": "2026-01-03 15:05:23.983099",
"modified": "2026-06-14 13:44:08.297053",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM",
"number_cards": [
{
"label": "New Lead (Last 1 Month)",
"number_card_name": "New Lead (Last 1 Month)"
},
{
"label": "New Opportunity (Last 1 Month)",
"number_card_name": "New Opportunity (Last 1 Month)"
},
{
"label": "Won Opportunity (Last 1 Month)",
"number_card_name": "Won Opportunity (Last 1 Month)"
}
],
"number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
@@ -253,7 +433,552 @@
"restrict_to_domain": "",
"roles": [],
"sequence_id": 17.0,
"shortcuts": [],
"shortcuts": [
{
"color": "Blue",
"format": "{} Open",
"label": "Lead",
"link_to": "Lead",
"stats_filter": "{\"status\":\"Open\"}",
"type": "DocType"
},
{
"color": "Blue",
"format": "{} Assigned",
"label": "Opportunity",
"link_to": "Opportunity",
"stats_filter": "{\"_assign\": [\"like\", '%' + frappe.session.user + '%']}",
"type": "DocType"
},
{
"label": "Customer",
"link_to": "Customer",
"type": "DocType"
},
{
"label": "Sales Analytics",
"link_to": "Sales Analytics",
"report_ref_doctype": "Sales Order",
"type": "Report"
},
{
"label": "Dashboard",
"link_to": "CRM",
"type": "Dashboard"
}
],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "CRM",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "users-round",
"indent": 0,
"keep_closed": 0,
"label": "Lead",
"link_to": "Lead",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "lightbulb",
"indent": 0,
"keep_closed": 0,
"label": "Opportunity",
"link_to": "Opportunity",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "customer",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Sales Analytics",
"link_to": "Sales Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead Details",
"link_to": "Lead Details",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Pipeline Analytics",
"link_to": "Sales Pipeline Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Opportunity Summary by Sales Stage",
"link_to": "Opportunity Summary by Sales Stage",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Funnel",
"link_to": "sales-funnel",
"link_type": "Page",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Prospects Engaged But Not Converted",
"link_to": "Prospects Engaged But Not Converted",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "First Response Time for Opportunity",
"link_to": "First Response Time for Opportunity",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Campaign Efficiency",
"link_to": "Campaign Efficiency",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead Owner Efficiency",
"link_to": "Lead Owner Efficiency",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Maintenance Schedule",
"link_to": "Maintenance Schedule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Maintenance Visit",
"link_to": "Maintenance Visit",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Warranty Claim",
"link_to": "Warranty Claim",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "funnel",
"indent": 1,
"keep_closed": 1,
"label": "Sales Pipeline",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead",
"link_to": "Lead",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Opportunity",
"link_to": "Opportunity",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer",
"link_to": "Customer",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Contract",
"link_to": "Contract",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Appointment",
"link_to": "Appointment",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Communication",
"link_to": "Communication",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "sell",
"indent": 1,
"keep_closed": 1,
"label": "Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Campaign",
"link_to": "Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Email Campaign",
"link_to": "Email Campaign",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "SMS Center",
"link_to": "SMS Center",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "SMS Log",
"link_to": "SMS Log",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Email Group",
"link_to": "Email Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Territory",
"link_to": "Territory",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Customer Group",
"link_to": "Customer Group",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Contact",
"link_to": "Contact",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Prospect",
"link_to": "Prospect",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Person",
"link_to": "Sales Person",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Stage",
"link_to": "Sales Stage",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Lead Source",
"link_to": "UTM Source",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 1,
"keep_closed": 1,
"label": "Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "CRM Settings",
"link_to": "CRM Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "SMS Settings",
"link_to": "SMS Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "CRM",
"type": "Workspace"
}

View File

@@ -8,7 +8,7 @@ app_email = "hello@frappe.io"
app_license = "GNU General Public License (v3)"
source_link = "https://github.com/frappe/erpnext"
app_logo_url = "/assets/erpnext/images/erpnext-logo.svg"
app_home = "/desk"
app_home = "/desk/home"
add_to_apps_screen = [
{

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
"PO-Revision-Date: 2026-06-29 20:08\n"
"PO-Revision-Date: 2026-07-01 20:39\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@@ -14218,7 +14218,7 @@ msgstr "آدرس فعلی"
#. Label of the current_accommodation_type (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Current Address Is"
msgstr "آدرس فعلی است"
msgstr "آدرس فعلی"
#. Label of the current_amount (Currency) field in DocType 'Stock
#. Reconciliation Item'
@@ -17767,7 +17767,7 @@ msgstr "سود سهام پرداخت شده"
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Divorced"
msgstr "جدا شده"
msgstr "طلاق گرفته"
#. Option for the 'Status' (Select) field in DocType 'Lead'
#: erpnext/crm/doctype/lead/lead.json
@@ -30241,7 +30241,7 @@ msgstr "متخصص بازاریابی"
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Married"
msgstr "متاهل"
msgstr "متأهل"
#: erpnext/setup/setup_wizard/data/marketing_source.txt:7
msgid "Mass Mailing"
@@ -34688,7 +34688,7 @@ msgstr ""
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Owned"
msgstr "مالکیت"
msgstr "ملکی"
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.js:29
#: erpnext/accounts/report/sales_payment_summary/sales_payment_summary.py:24
@@ -37162,7 +37162,7 @@ msgstr "آدرس دائمی"
#. 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Permanent Address Is"
msgstr "آدرس دائمی است"
msgstr "آدرس دائمی"
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:73
#: erpnext/accounts/doctype/bank_statement_import_log/bank_statement_import_log.py:77
@@ -44208,7 +44208,7 @@ msgstr "اجاره"
#. Option for the 'Current Address Is' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Rented"
msgstr "اجاره شده"
msgstr "استیجاری"
#. Label of the reorder_level (Float) field in DocType 'Material Request Item'
#: erpnext/stock/doctype/material_request_item/material_request_item.json
@@ -50940,7 +50940,7 @@ msgstr ""
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Single"
msgstr "تنها"
msgstr "مجرد"
#. Option for the 'Bank Entry Type' (Select) field in DocType 'Bank Transaction
#. Rule'
@@ -60982,7 +60982,7 @@ msgstr "هنگام تهیه فاکتور خرید از سفارش خرید، ب
#. Option for the 'Marital Status' (Select) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
msgid "Widowed"
msgstr "بیوه"
msgstr "همسر فوت شده"
#. Label of the width (Float) field in DocType 'Shipment Parcel'
#. Label of the width (Float) field in DocType 'Shipment Parcel Template'
@@ -61884,7 +61884,7 @@ msgstr "تراز صفر"
#: erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py:353
msgid "Zero Balance Journal: {0}"
msgstr ""
msgstr "دفتر تراز صفر: {0}"
#: erpnext/regional/report/uae_vat_201/uae_vat_201.py:78
msgid "Zero Rated"
@@ -62008,7 +62008,7 @@ msgstr "fieldname"
#: erpnext/setup/doctype/item_group/item_group.py:49
msgid "for tax category {0}"
msgstr ""
msgstr "برای دسته بندی مالیاتی {0}"
#. Option for the 'Service Provider' (Select) field in DocType 'Currency
#. Exchange Settings'
@@ -62375,7 +62375,7 @@ msgstr ""
#: erpnext/public/js/utils/sales_common.js:336
msgid "{0} cannot be greater than 100"
msgstr ""
msgstr "{0} نمی‌تواند بزرگتر از ۱۰۰ باشد"
#: erpnext/accounts/doctype/cost_center_allocation/cost_center_allocation.py:136
msgid "{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}"
@@ -62444,7 +62444,7 @@ msgstr "{0} با موفقیت ارسال شد"
#: erpnext/controllers/buying_controller.py:289
msgid "{0} has submitted assets linked to it. You need to cancel the assets to create purchase return."
msgstr ""
msgstr "{0} دارایی‌های مرتبط با آن را ارسال کرده است. برای ایجاد بازگشت خرید، باید دارایی‌ها را لغو کنید."
#: erpnext/projects/doctype/project/project_dashboard.html:15
msgid "{0} hours"
@@ -62456,7 +62456,7 @@ msgstr "{0} در ردیف {1}"
#: erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py:66
msgid "{0} is a child company."
msgstr ""
msgstr "{0} یک شرکت فرزند است."
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:465
msgid "{0} is a child table and will be deleted automatically with its parent"
@@ -62539,7 +62539,7 @@ msgstr "{0} در {1} فعال نیست"
#: erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py:649
msgid "{0} is not running. Cannot trigger events for this document"
msgstr ""
msgstr "{0} در حال اجرا نیست. نمی‌توان رویدادها را برای این سند فعال کرد"
#: erpnext/stock/doctype/material_request/material_request.py:478
msgid "{0} is not the default supplier for any items."
@@ -62547,7 +62547,7 @@ msgstr "{0} تامین کننده پیش‌فرض هیچ موردی نیست."
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:2686
msgid "{0} is on hold until {1}"
msgstr ""
msgstr "{0} تا زمان {1} در حالت انتظار است"
#: erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py:68
msgid "{0} is open. Close the POS or cancel the existing POS Opening Entry to create a new POS Opening Entry."
@@ -62722,11 +62722,11 @@ msgstr ""
#: erpnext/accounts/doctype/party_link/party_link.py:53
#: erpnext/accounts/doctype/party_link/party_link.py:63
msgid "{0} {1} is already linked with another {2}"
msgstr ""
msgstr "{0} {1} از قبل به {2} دیگری لینک شده است"
#: erpnext/accounts/doctype/party_link/party_link.py:40
msgid "{0} {1} is already linked with {2} {3}"
msgstr ""
msgstr "{0} {1} از قبل به {2} {3} لینک شده است"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:711
msgid "{0} {1} is associated with {2}, but Party Account is {3}"
@@ -62767,7 +62767,7 @@ msgstr "{0} {1} فعال نیست"
#: erpnext/accounts/doctype/bank_transaction/bank_transaction.py:452
msgid "{0} {1} is not affecting bank account {2}"
msgstr ""
msgstr "{0} {1} تاثیری بر حساب بانکی {2} ندارد"
#: erpnext/accounts/doctype/payment_entry/payment_entry.py:688
msgid "{0} {1} is not associated with {2} {3}"

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: hello@frappe.io\n"
"POT-Creation-Date: 2026-06-28 10:20+0000\n"
"PO-Revision-Date: 2026-06-29 20:08\n"
"PO-Revision-Date: 2026-07-01 20:39\n"
"Last-Translator: hello@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -8227,7 +8227,7 @@ msgstr "Saldo Historik per Parti"
#: erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py:183
#: erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py:86
msgid "Batchwise Valuation"
msgstr "Partivis Värdering"
msgstr "Partibaserad Värdering"
#. Label of the section_break_3 (Section Break) field in DocType 'Stock
#. Reconciliation Item'
@@ -9319,7 +9319,7 @@ msgstr "Beräkna Uppskatade Ankomst Tider"
#. Settings'
#: erpnext/selling/doctype/selling_settings/selling_settings.json
msgid "Calculate Product Bundle price based on child Item's rates"
msgstr "Beräkna Artikel Paket pris baserat på priser för underordnade artiklar"
msgstr "Beräkna Artikel Paket pris baserat på priser för paket artiklar"
#. Description of the 'Hidden Line (Internal Use Only)' (Check) field in
#. DocType 'Financial Report Row'
@@ -15680,7 +15680,7 @@ msgstr "Avdraget från"
#. Deduction Certificate'
#: erpnext/regional/doctype/lower_deduction_certificate/lower_deduction_certificate.json
msgid "Deductee Details"
msgstr "Avdragsberättigad Detaljer"
msgstr "Avdragstagare Detaljer"
#. Label of a Workspace Sidebar Item
#: erpnext/workspace_sidebar/taxes.json
@@ -17908,7 +17908,7 @@ msgstr "Uppdatera inte Varianter vid Spara"
#. Settings'
#: erpnext/stock/doctype/stock_settings/stock_settings.json
msgid "Do not use Batch-wise Valuation"
msgstr "Använd inte Partivis Värdering"
msgstr "Använd inte Partibaserad Värdering"
#: erpnext/assets/doctype/asset/asset.js:957
msgid "Do you really want to restore this scrapped asset?"
@@ -19193,9 +19193,9 @@ msgid "Enabling this will do the following:\n"
msgstr "Om du aktiverar detta kommer följande att hända:\n"
"<ul style=\"padding-left:16px\">\n"
"<li>Pris Kolumn i alla Artikel Paket tabeller redigerbar.</li>\n"
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">artikel paket</a> i Artikel tabell, baserat på priser för dess underordnade artiklar, som anges i Artikel Paket tabell. </li>\n"
"<li>Beräkna priser för alla <a href=\"/desk/product-bundle\" rel=\"noopener noreferrer\">Artikel Paket</a> i Artikel tabell, baserat på priser för paket artiklar, som anges i Artikel Paket tabell. </li>\n"
"</ul>\n"
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra dess pris. Det kommer att återställas till det pris som baseras på dess underordnade artiklar när dokumentet sparas."
"Observera: Om detta är aktiverat kommer uppdatering av pris för artikel paket i artikel tabell inte att ändra deras pris. Det kommer att återställas till pris som baseras på paket artiklar när dokument sparas."
#. Label of the encashment_date (Date) field in DocType 'Employee'
#: erpnext/setup/doctype/employee/employee.json
@@ -33834,7 +33834,7 @@ msgstr "Öppning Faktura Verktyg"
#: erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py:832
#: erpnext/accounts/doctype/sales_invoice/services/gl_composer.py:642
msgid "Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
msgstr "Öppning Fakturan har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
msgstr "Öppning Faktura har avrundning justering på {0}.<br><br> '{1}' konto erfordras för att bokföra dessa värden. Ange det i Bolag: {2}.<br><br> Eller så kan '{3}' aktiveras för att inte bokföra någon avrundning justering."
#: erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool_dashboard.html:8
msgid "Opening Invoices"
@@ -59194,7 +59194,7 @@ msgstr "Använd <strong>Python</strong> filter för att hämta Konton"
#. Label of the use_batchwise_valuation (Check) field in DocType 'Batch'
#: erpnext/stock/doctype/batch/batch.json
msgid "Use Batch-wise Valuation"
msgstr "Använd Partivis Värdering"
msgstr "Använd Partibaserad Värdering"
#. Label of the use_csv_sniffer (Check) field in DocType 'Bank Statement
#. Import'
@@ -60825,7 +60825,7 @@ msgstr "Garanti Utgång (Serienummer)"
#: erpnext/stock/doctype/serial_no/serial_no.json
#: erpnext/support/doctype/warranty_claim/warranty_claim.json
msgid "Warranty Expiry Date"
msgstr "Garanti Utgångsdatum"
msgstr "Garanti Utgång Datum"
#. Label of the warranty_period (Int) field in DocType 'Serial No'
#: erpnext/stock/doctype/serial_no/serial_no.json

View File

@@ -739,7 +739,7 @@ class BOM(WebsiteGenerator):
)
)
def check_recursion(self, bom_list=None):
def check_recursion(self):
"""Check whether recursion occurs in any bom"""
bom_list = self.traverse_tree()
child_items = frappe.get_all(
@@ -861,21 +861,30 @@ class BOM(WebsiteGenerator):
self.append("items", row)
def traverse_tree(self, bom_list=None):
count = 0
if not bom_list:
bom_list = []
def traverse_tree(self):
"""Return this BOM and every descendant BOM. The whole sub-tree is fetched in one recursive
CTE (frappe.qb) instead of a query-per-node walk; the only caller (check_recursion) uses the
result purely as a membership set. Portable across postgres and mariadb 10.2+."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("bom_tree")
if self.name not in bom_list:
bom_list.append(self.name)
seed = (
frappe.qb.from_(bom_item)
.select(bom_item.bom_no.as_("bom"))
.where((bom_item.parent == self.name) & (bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.parent == tree.bom)
.select(bom_item.bom_no)
.where((bom_item.bom_no != "") & (bom_item.parenttype == "BOM"))
)
descendants = (
frappe.qb.with_(seed + recursion, "bom_tree", recursive=True).from_(tree).select(tree.bom)
).run(pluck=True)
while count < len(bom_list):
for child_bom in _get_bom_children(bom_list[count]):
if child_bom not in bom_list:
bom_list.append(child_bom)
count += 1
bom_list.reverse()
return bom_list
return [self.name, *descendants]
def company_currency(self):
return erpnext.get_company_currency(self.company)

View File

@@ -881,8 +881,13 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
warehouse_list = [warehouse_list]
if not warehouse_list:
# Reconcile every warehouse the item has a non-zero balance in -- including
# negative balances left by other tests. `get_valuation_rate` averages
# Sum(stock_value)/Sum(actual_qty) across all bins, so a leftover negative
# balance in one warehouse can cancel the reset qty elsewhere and make the
# average collapse to 0, which is a source of flaky BOM-cost failures.
warehouse_list = frappe.get_all(
"Bin", filters={"item_code": item_code, "actual_qty": [">", 0]}, pluck="warehouse"
"Bin", filters={"item_code": item_code, "actual_qty": ["!=", 0]}, pluck="warehouse"
)
if not warehouse_list:

View File

@@ -67,29 +67,33 @@ def update_cost_in_level(doc: "BOMUpdateLog", bom_list: list[str], batch_name: i
frappe.db.commit() # nosemgrep
def get_ancestor_boms(new_bom: str, bom_list: list | None = None) -> list:
"Recursively get all ancestors of BOM."
bom_list = bom_list or []
def get_ancestor_boms(new_bom: str) -> list:
"""Return every ancestor BOM of `new_bom` (BOMs that consume it, transitively) in one recursive
CTE built with frappe.qb -- portable across postgres and mariadb 10.2+. `UNION` makes it
cycle-safe (it stops once no new BOM is reached); a BOM that is its own ancestor is rejected."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("ancestor_boms")
parents = (
seed = (
frappe.qb.from_(bom_item)
.select(bom_item.parent)
.select(bom_item.parent.as_("bom"))
.where((bom_item.bom_no == new_bom) & (bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
.run(as_dict=True)
)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.bom_no == tree.bom)
.select(bom_item.parent)
.where((bom_item.docstatus < 2) & (bom_item.parenttype == "BOM"))
)
ancestors = (
frappe.qb.with_(seed + recursion, "ancestor_boms", recursive=True).from_(tree).select(tree.bom)
).run(pluck=True)
for d in parents:
if new_bom == d.parent:
frappe.throw(_("BOM recursion: {0} cannot be child of {1}").format(new_bom, d.parent))
if new_bom in ancestors:
frappe.throw(_("BOM recursion: {0} cannot be an ancestor of itself").format(new_bom))
if d.parent not in tuple(bom_list):
bom_list.append(d.parent)
get_ancestor_boms(d.parent, bom_list)
return bom_list
return ancestors
def update_new_bom_in_bom_items(unit_cost: float, current_bom: str, new_bom: str) -> None:

View File

@@ -2322,6 +2322,145 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_no_stock_reservation_via_purchase_receipt_when_reserve_stock_disabled(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.material_request.mapper import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
bom_tree = {"FG For SR No Auto Reserve": {"RM For SR No Auto Reserve": {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
warehouse = "_Test Warehouse - _TC"
# reserve_stock is deliberately left unset (defaults to 0): this is what happens when
# "Auto Reserve Stock" is off and nobody ticks "Reserve Stock" on the Production Plan by hand.
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
self.assertEqual(plan.reserve_stock, 0)
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
material_requests = frappe.get_all(
"Material Request", filters={"production_plan": plan.name}, pluck="name"
)
self.assertGreater(len(material_requests), 0)
for mr_name in list(set(material_requests)):
po = make_purchase_order(mr_name)
po.supplier = "_Test Supplier"
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_ignores_production_plans_with_reserve_stock_off_on_shared_purchase_order(self):
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
warehouse = "_Test Warehouse - _TC"
bom_reserve = create_nested_bom({"FG SR Mixed Reserve": {"RM SR Mixed Reserve": {}}}, prefix="")
bom_skip = create_nested_bom({"FG SR Mixed Skip": {"RM SR Mixed Skip": {}}}, prefix="")
def make_submitted_plan(item_code, reserve_stock):
plan = create_production_plan(
item_code=item_code,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
reserve_stock=reserve_stock,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
return plan
plan_reserve = make_submitted_plan(bom_reserve.item, reserve_stock=1)
plan_skip = make_submitted_plan(bom_skip.item, reserve_stock=0)
self.assertEqual(plan_reserve.reserve_stock, 1)
self.assertEqual(plan_skip.reserve_stock, 0)
mr_reserve = frappe.get_all(
"Material Request", filters={"production_plan": plan_reserve.name}, pluck="name"
)[0]
mr_skip = frappe.get_all(
"Material Request", filters={"production_plan": plan_skip.name}, pluck="name"
)[0]
# One Purchase Order pulling rows from both Material Requests, so the Purchase Receipt made
# from it has both a reservable and a non-reservable Production Plan reference in `doc.items`.
po = frappe.new_doc("Purchase Order")
po.supplier = "_Test Supplier"
po.company = plan_reserve.company
po.schedule_date = nowdate()
for mr_name in (mr_reserve, mr_skip):
mr = frappe.get_doc("Material Request", mr_name)
for item in mr.items:
po.append(
"items",
{
"item_code": item.item_code,
"qty": item.qty,
"rate": 100,
"schedule_date": nowdate(),
"warehouse": warehouse,
"material_request": mr.name,
"material_request_item": item.name,
},
)
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
reserved_for_plan_reserve = StockReservation(plan_reserve).get_reserved_entries(
"Production Plan", plan_reserve.name
)
reserved_for_plan_skip = StockReservation(plan_skip).get_reserved_entries(
"Production Plan", plan_skip.name
)
self.assertGreater(len(reserved_for_plan_reserve), 0)
self.assertEqual(len(reserved_for_plan_skip), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_restored_on_work_order_cancel(self):
# Spec #5 (cancellation path): when a Work Order created from a Production Plan is cancelled,
# the reservation that was transferred PP -> WO must flow back to the still-open Production

View File

@@ -149,6 +149,16 @@ class RequiredItemsService:
self.recompute_material_transferred_for_manufacturing(transferred_items)
def refresh_material_transferred_for_manufacturing(self):
"""Recompute material_transferred_for_manufacturing only, without touching per-row
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
"""
if self.doc.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the

View File

@@ -87,6 +87,12 @@ class StatusService:
def update_status(self, status=None):
"""Update status of work order if unknown"""
if self.doc.docstatus == 1:
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
# are reflected immediately, instead of only after the next status update call.
self.doc.refresh_material_transferred_for_manufacturing()
if self.doc.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)
@@ -126,7 +132,9 @@ class StatusService:
status = (
"In Process"
if flt(self.doc.material_transferred_for_manufacturing) > 0 or self.doc.skip_transfer
if flt(self.doc.material_transferred_for_manufacturing) > 0
or self.doc.skip_transfer
or self._has_transferred_material()
else "Not Started"
)
precision = frappe.get_precision("Work Order", "produced_qty")
@@ -135,6 +143,26 @@ class StatusService:
status = "Completed"
return status
def _has_transferred_material(self):
"""True if any raw material was transferred against this work order via a pick list
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
qty = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(Sum(ste_child.transfer_qty))
.where(
(ste.work_order == self.doc.name)
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.pick_list.isnotnull())
)
).run()[0][0]
return flt(qty) > 0
def _is_partial_skip_transfer(self):
return bool(
self.doc.skip_transfer

View File

@@ -1528,6 +1528,38 @@ class TestWorkOrder(ERPNextTestSuite):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_status_in_process_when_only_one_required_item_transferred(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
still move to In Process because material is already in WIP."""
from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
from erpnext.stock.doctype.pick_list.mapper import create_stock_entry
work_order = make_wo_order_test_record(
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
)
pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
# pick only _Test Item; the other required item is left out of this pick list
pick_list.pick_manually = 1
pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
pick_list.save()
pick_list.submit()
stock_entry = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
self.assertEqual(work_order.status, "In Process")
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -1003,6 +1003,9 @@ class WorkOrder(Document):
def update_transferred_qty_for_required_items(self):
return RequiredItemsService(self).update_transferred_qty_for_required_items()
def refresh_material_transferred_for_manufacturing(self):
return RequiredItemsService(self).refresh_material_transferred_for_manufacturing()
def update_returned_qty(self):
return RequiredItemsService(self).update_returned_qty()

View File

@@ -2,6 +2,8 @@
# For license information, please see license.txt
from collections import defaultdict
import frappe
from frappe import _
@@ -14,29 +16,47 @@ def execute(filters=None):
def get_data(filters, data):
get_exploded_items(filters.bom, data)
children_map = fetch_exploded_bom_items(filters.bom)
build_exploded_rows(filters.bom, children_map, data)
def get_exploded_items(bom, data, indent=0, qty=1):
exploded_items = frappe.get_all(
"BOM Item",
filters={"parent": bom},
fields=[
"qty",
"bom_no",
"qty",
"item_code",
"item_name",
"description",
"uom",
"idx",
"is_phantom_item",
],
order_by="idx ASC",
def fetch_exploded_bom_items(root_bom):
"""Every BOM Item in the exploded tree of `root_bom`, grouped by its parent BOM, in one
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
and fetches each sub-BOM's items only once even when it is reused across the tree."""
bom_item = frappe.qb.DocType("BOM Item")
tree = frappe.qb.Table("exploded_bom")
fields = [
bom_item.parent,
bom_item.qty,
bom_item.bom_no,
bom_item.item_code,
bom_item.item_name,
bom_item.description,
bom_item.uom,
bom_item.idx,
bom_item.is_phantom_item,
]
seed = frappe.qb.from_(bom_item).select(*fields).where(bom_item.parent == root_bom)
recursion = (
frappe.qb.from_(bom_item)
.join(tree)
.on(bom_item.parent == tree.bom_no)
.select(*fields)
.where(tree.bom_no != "")
)
rows = (
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
).run(as_dict=True)
for item in exploded_items:
item["indent"] = indent
children_map = defaultdict(list)
for row in rows:
children_map[row.parent].append(row)
return children_map
def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
for item in sorted(children_map.get(bom, []), key=lambda row: row.idx):
data.append(
{
"item_code": item.item_code,
@@ -51,7 +71,7 @@ def get_exploded_items(bom, data, indent=0, qty=1):
}
)
if item.bom_no:
get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty)
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
def get_columns():

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestBOMExplorer(ERPNextTestSuite):
def setUp(self):
# the tests look up `_Test FG Item`'s BOM, which comes from the BOM fixtures;
# load them so the file also passes when run in isolation
self.load_test_records("BOM")
def run_report(self, bom):
filters = frappe._dict({"bom": bom})
return execute(filters)[1]
def top_level_rows_by_item(self, data):
# key only the direct (indent 0) components, so an item that also appears in a
# deeper sub-assembly can't overwrite the top-level row we assert against
return {row["item_code"]: row for row in data if row["indent"] == 0}
def test_default_bom_lists_components_at_top_level(self):
bom = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_active": 1, "is_default": 1})
self.assertIsNotNone(bom, "Default active BOM for _Test FG Item not found")
data = self.run_report(bom)
rows_by_item = self.top_level_rows_by_item(data)
self.assertIn("_Test Item", rows_by_item)
self.assertIn("_Test Item Home Desktop 100", rows_by_item)
for item_code in ("_Test Item", "_Test Item Home Desktop 100"):
row = rows_by_item[item_code]
self.assertEqual(row["indent"], 0)
self.assertEqual(row["bom_level"], 0)
def test_qty_matches_bom_item_qty(self):
bom = frappe.db.get_value("BOM", {"item": "_Test FG Item", "is_active": 1, "is_default": 1})
data = self.run_report(bom)
rows_by_item = self.top_level_rows_by_item(data)
for bom_item in frappe.get_all(
"BOM Item", filters={"parent": bom}, fields=["item_code", "qty", "uom"]
):
row = rows_by_item[bom_item.item_code]
self.assertEqual(row["qty"], bom_item.qty)
self.assertEqual(row["uom"], bom_item.uom)
def test_nested_bom_shows_deeper_level(self):
# Sub-assembly: "sub" is itself a BOM containing "leaf".
parent_bom = create_nested_bom(
{"parent": {"sub": {"leaf": {}}, "flat": {}}},
prefix="_Test explorer ",
)
data = self.run_report(parent_bom.name)
rows_by_item = {row["item_code"]: row for row in data}
sub_item = "_Test explorer sub"
leaf_item = "_Test explorer leaf"
flat_item = "_Test explorer flat"
self.assertIn(sub_item, rows_by_item)
self.assertIn(flat_item, rows_by_item)
self.assertIn(leaf_item, rows_by_item)
# Direct components of the parent sit at level 0.
self.assertEqual(rows_by_item[flat_item]["indent"], 0)
self.assertEqual(rows_by_item[sub_item]["indent"], 0)
# The sub-assembly row carries its own BOM reference.
self.assertTrue(rows_by_item[sub_item]["bom"])
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)

View File

@@ -0,0 +1,117 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.bom_operations_time.bom_operations_time import execute
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
OPERATION = "_Test BOM Ops Time Operation"
WORKSTATION = "_Test BOM Ops Time Workstation"
OTHER_OPERATION = "_Test BOM Ops Time Operation 2"
OTHER_WORKSTATION = "_Test BOM Ops Time Workstation 2"
TIME_IN_MINS = 45
class TestBOMOperationsTime(ERPNextTestSuite):
def setUp(self):
ensure_workstation_and_operation(WORKSTATION, OPERATION)
self.rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).name
self.fg_item = make_item(properties={"is_stock_item": 1}).name
self.bom = build_bom_with_operation(self.fg_item, self.rm_item, OPERATION, WORKSTATION)
def run_report(self, **filters):
return execute(frappe._dict(filters))[1]
def bom_names(self, rows):
return {row.name for row in rows}
def build_other_bom(self):
"""A submitted BOM for a different item, built on a different workstation."""
ensure_workstation_and_operation(OTHER_WORKSTATION, OTHER_OPERATION)
other_fg = make_item(properties={"is_stock_item": 1}).name
return build_bom_with_operation(other_fg, self.rm_item, OTHER_OPERATION, OTHER_WORKSTATION)
def test_operation_row_appears_with_expected_values(self):
rows = self.run_report(bom_id=[self.bom.name])
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row.name, self.bom.name)
self.assertEqual(row.item, self.fg_item)
self.assertEqual(row.operation, OPERATION)
self.assertEqual(row.workstation, WORKSTATION)
self.assertEqual(row.time_in_mins, TIME_IN_MINS)
def test_item_code_filter_includes_matching_and_excludes_other(self):
other_bom = self.build_other_bom()
# no bom_id here, so the item_code filter alone must scope the result
names = self.bom_names(self.run_report(item_code=self.fg_item))
self.assertIn(self.bom.name, names)
self.assertNotIn(other_bom.name, names)
# reverse direction: filtering the other item drops our BOM
other_names = self.bom_names(self.run_report(item_code=other_bom.item))
self.assertIn(other_bom.name, other_names)
self.assertNotIn(self.bom.name, other_names)
def test_workstation_filter_includes_matching_and_excludes_other(self):
other_bom = self.build_other_bom()
# no bom_id here, so the workstation filter alone must scope the result
names = self.bom_names(self.run_report(workstation=WORKSTATION))
self.assertIn(self.bom.name, names)
self.assertNotIn(other_bom.name, names)
# reverse direction: filtering the other workstation drops our BOM
other_names = self.bom_names(self.run_report(workstation=OTHER_WORKSTATION))
self.assertIn(other_bom.name, other_names)
self.assertNotIn(self.bom.name, other_names)
def test_draft_bom_excluded(self):
draft_bom = build_bom_with_operation(
make_item(properties={"is_stock_item": 1}).name,
self.rm_item,
OPERATION,
WORKSTATION,
do_not_submit=True,
)
rows = self.run_report(bom_id=[draft_bom.name])
self.assertEqual(rows, [])
def ensure_workstation_and_operation(workstation, operation):
if not frappe.db.exists("Workstation", workstation):
frappe.get_doc({"doctype": "Workstation", "workstation_name": workstation}).insert(
ignore_permissions=True
)
if not frappe.db.exists("Operation", operation):
frappe.get_doc({"doctype": "Operation", "name": operation, "workstation": workstation}).insert(
ignore_permissions=True
)
def build_bom_with_operation(fg_item, rm_item, operation, workstation, do_not_submit=False):
bom = make_bom(
item=fg_item,
raw_materials=[rm_item],
with_operations=1,
do_not_save=True,
)
bom.append(
"operations",
{
"operation": operation,
"workstation": workstation,
"time_in_mins": TIME_IN_MINS,
"hour_rate": 100,
},
)
bom.insert(ignore_permissions=True)
if not do_not_submit:
bom.submit()
return bom

View File

@@ -0,0 +1,110 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.bom_variance_report.bom_variance_report import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestBOMVarianceReport(ERPNextTestSuite):
def setUp(self):
self.production_item = "_Test FG Item"
self.warehouse = "_Test Warehouse - _TC"
self.bom_no = frappe.db.get_value(
"BOM", {"item": self.production_item, "is_active": 1, "is_default": 1}
)
self.raw_materials = self.get_bom_raw_materials()
# allow over-production so a Work Order can produce more than planned; ERPNextTestSuite
# rolls this back at tearDown, so no manual restore is needed
frappe.db.set_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order", 100)
def get_bom_raw_materials(self):
return {
row.item_code: row.qty
for row in frappe.get_all(
"BOM Item", filters={"parent": self.bom_no}, fields=["item_code", "qty"]
)
}
def create_over_produced_work_order(self, ordered_qty=2, produced_qty=3):
work_order = make_wo_order_test_record(
item=self.production_item,
qty=ordered_qty,
source_warehouse=self.warehouse,
skip_transfer=1,
)
for item_code in self.raw_materials:
test_stock_entry.make_stock_entry(
item_code=item_code, target=self.warehouse, qty=100, basic_rate=100
)
stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", produced_qty))
stock_entry.submit()
work_order.reload()
self.assertEqual(work_order.produced_qty, produced_qty)
return work_order
def run_report(self, **extra):
filters = frappe._dict({"bom_no": self.bom_no, **extra})
return execute(filters)[1]
def test_over_produced_work_order_appears_with_planned_and_actual(self):
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=3)
data = self.run_report(work_order=work_order.name)
summary_rows = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(len(summary_rows), 1)
summary = summary_rows[0]
self.assertEqual(summary.get("production_item"), self.production_item)
self.assertEqual(summary.get("bom_no"), self.bom_no)
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 3)
raw_material_rows = {
row.get("raw_material_code"): row for row in data if row.get("raw_material_code")
}
for item_code, per_unit_qty in self.raw_materials.items():
self.assertIn(item_code, raw_material_rows)
# planned/required qty scales with the ordered qty on the work order
self.assertEqual(raw_material_rows[item_code].get("required_qty"), per_unit_qty * 2)
def test_bom_no_filter_returns_over_produced_orders(self):
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=3)
data = self.run_report()
matched = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(len(matched), 1)
self.assertEqual(matched[0].get("bom_no"), self.bom_no)
def test_unstarted_work_order_is_excluded(self):
work_order = make_wo_order_test_record(
item=self.production_item,
qty=2,
source_warehouse=self.warehouse,
skip_transfer=1,
)
data = self.run_report(work_order=work_order.name)
matched = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(matched, [])
def test_work_order_produced_exactly_on_plan_is_excluded(self):
# the canonical no-variance case: produced qty equals the planned qty, so the
# report (which lists only over-produced orders) must not include it
work_order = self.create_over_produced_work_order(ordered_qty=2, produced_qty=2)
data = self.run_report(work_order=work_order.name)
matched = [row for row in data if row.get("work_order") == work_order.name]
self.assertEqual(matched, [])

View File

@@ -120,6 +120,12 @@ def get_columns(filters):
"options": "Workstation",
"width": "100",
},
{
"label": _("Hour Rate"),
"fieldtype": "Currency",
"fieldname": "hour_rate",
"width": "120",
},
{
"label": _("Operating Cost"),
"fieldtype": "Currency",

View File

@@ -0,0 +1,130 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils.data import add_to_date, now
from erpnext.manufacturing.doctype.job_card.mapper import make_corrective_job_card
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.cost_of_poor_quality_report.cost_of_poor_quality_report import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestCostOfPoorQualityReport(ERPNextTestSuite):
"""A Job Card appears in this report only when it is submitted (docstatus == 1) and flagged
as a corrective job card (is_corrective_job_card == 1). Such a card is created against a
corrective Operation (is_corrective_operation == 1); without any corrective operation the
report returns no rows at all."""
def setUp(self):
self.load_test_records("BOM")
def create_corrective_job_card(self, hour_rate=100):
"""Produce a submitted corrective Job Card and return (corrective_jc, operation, workstation)."""
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
job_card = frappe.get_last_doc("Job Card", {"work_order": work_order.name})
job_card.append(
"time_logs",
{"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2},
)
job_card.submit()
corrective_operation = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
corrective_job_card = make_corrective_job_card(
job_card.name, operation=corrective_operation.name, for_operation=job_card.operation
)
corrective_job_card.hour_rate = hour_rate
corrective_job_card.insert()
corrective_job_card.append(
"time_logs",
{
"from_time": add_to_date(now(), hours=2),
"to_time": add_to_date(now(), hours=2, minutes=30),
"completed_qty": 2,
},
)
corrective_job_card.submit()
return corrective_job_card, corrective_operation.name, corrective_job_card.workstation
def run_report(self, **filters):
return execute(frappe._dict(filters))[1]
def test_corrective_job_card_is_listed_with_expected_fields(self):
corrective_jc, operation, workstation = self.create_corrective_job_card(hour_rate=100)
rows = self.run_report(company="_Test Company")
row = next((r for r in rows if r["name"] == corrective_jc.name), None)
self.assertIsNotNone(row, "Submitted corrective job card must appear in the report")
self.assertEqual(row["work_order"], corrective_jc.work_order)
self.assertEqual(row["operation"], operation)
self.assertEqual(row["workstation"], workstation)
self.assertEqual(row["item_code"], corrective_jc.production_item)
self.assertEqual(row["hour_rate"], 100)
self.assertEqual(row["total_time_in_mins"], corrective_jc.total_time_in_mins)
# operating_cost = hour_rate * total_time_in_mins / 60 (SQL float -> compare approximately)
self.assertAlmostEqual(row["operating_cost"], 100 * corrective_jc.total_time_in_mins / 60.0, places=6)
def test_non_corrective_job_card_is_excluded(self):
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
# The regular (non-corrective) job card the corrective one was raised against must not appear.
regular_jc = corrective_jc.for_job_card
rows = self.run_report(company="_Test Company")
self.assertNotIn(regular_jc, {r["name"] for r in rows})
def test_operation_filter_scopes_rows(self):
corrective_jc, operation, _workstation = self.create_corrective_job_card()
matching = self.run_report(company="_Test Company", operation=operation)
self.assertIn(corrective_jc.name, {r["name"] for r in matching})
other_operation = frappe.get_doc(
doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
).insert()
filtered = self.run_report(company="_Test Company", operation=other_operation.name)
self.assertNotIn(corrective_jc.name, {r["name"] for r in filtered})
def test_workstation_filter_scopes_rows(self):
corrective_jc, _operation, workstation = self.create_corrective_job_card()
matching = self.run_report(company="_Test Company", workstation=workstation)
self.assertIn(corrective_jc.name, {r["name"] for r in matching})
filtered = self.run_report(company="_Test Company", workstation="__non_existent_ws__")
self.assertNotIn(corrective_jc.name, {r["name"] for r in filtered})
def test_work_order_and_name_filters_scope_rows(self):
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
by_work_order = self.run_report(company="_Test Company", work_order=corrective_jc.work_order)
self.assertIn(corrective_jc.name, {r["name"] for r in by_work_order})
by_name = self.run_report(company="_Test Company", name=corrective_jc.name)
self.assertEqual({r["name"] for r in by_name}, {corrective_jc.name})
def test_date_filter_scopes_rows(self):
corrective_jc, _operation, _workstation = self.create_corrective_job_card()
# Time logs sit ~2 hours from now; a window covering today includes the card.
within = self.run_report(
company="_Test Company",
work_order=corrective_jc.work_order,
from_date=add_to_date(now(), days=-1),
to_date=add_to_date(now(), days=1),
)
self.assertIn(corrective_jc.name, {r["name"] for r in within})
# A future-only window excludes it, proving the Job Card Time Log join filters by time.
outside = self.run_report(
company="_Test Company",
work_order=corrective_jc.work_order,
from_date=add_to_date(now(), days=5),
to_date=add_to_date(now(), days=6),
)
self.assertNotIn(corrective_jc.name, {r["name"] for r in outside})

View File

@@ -0,0 +1,92 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, get_datetime, today
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.manufacturing.report.downtime_analysis.downtime_analysis import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestDowntimeAnalysis(ERPNextTestSuite):
def setUp(self):
self.workstation = make_workstation(workstation="_Test Downtime Workstation").name
self.other_workstation = make_workstation(workstation="_Test Downtime Workstation 2").name
self.operator = make_employee("test_downtime_operator@example.com", company="_Test Company")
# from_time / to_time are two hours apart -> downtime of 120 minutes (2 hours).
self.from_time = get_datetime(f"{today()} 09:00:00")
self.to_time = get_datetime(f"{today()} 11:00:00")
self.entry = self.make_downtime_entry(self.workstation)
def make_downtime_entry(self, workstation, **extra):
values = {
"doctype": "Downtime Entry",
"workstation": workstation,
"operator": self.operator,
"from_time": self.from_time,
"to_time": self.to_time,
"stop_reason": "Machine malfunction",
}
values.update(extra)
return frappe.get_doc(values).insert()
def run_report(self, **extra):
filters = frappe._dict(
{
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
}
)
filters.update(extra)
return execute(filters)[1]
def row_for_entry(self, rows, name):
return next((row for row in rows if row.get("name") == name), None)
def test_downtime_is_computed_in_hours(self):
# validate() stores downtime in minutes; the report converts it to hours.
self.assertEqual(self.entry.downtime, 120)
row = self.row_for_entry(self.run_report(), self.entry.name)
self.assertIsNotNone(row, "Downtime Entry not present in report output")
self.assertEqual(row.get("workstation"), self.workstation)
self.assertEqual(row.get("operator"), self.operator)
self.assertEqual(row.get("stop_reason"), "Machine malfunction")
self.assertEqual(row.get("downtime"), 2.0)
def test_workstation_filter_scopes_rows(self):
other = self.make_downtime_entry(self.other_workstation)
rows = self.run_report(workstation=self.workstation)
names = {row.get("name") for row in rows}
self.assertIn(self.entry.name, names)
self.assertNotIn(other.name, names)
self.assertTrue(all(row.get("workstation") == self.workstation for row in rows))
def test_date_range_excludes_out_of_window_entries(self):
# The report filters from_time >= from_date and to_time <= to_date; a window
# ending before the entry's from_time must exclude it.
rows = self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
self.assertIsNone(self.row_for_entry(rows, self.entry.name))
def test_chart_aggregates_downtime_per_workstation(self):
self.make_downtime_entry(self.workstation)
chart = execute(
frappe._dict(
{
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
"workstation": self.workstation,
}
)
)[3]
self.assertIn(self.workstation, chart["data"]["labels"])
index = chart["data"]["labels"].index(self.workstation)
# Two entries of 2 hours each for this workstation -> 4 hours aggregated.
self.assertEqual(chart["data"]["datasets"][0]["values"][index], 4.0)

View File

@@ -0,0 +1,101 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.manufacturing.report.exponential_smoothing_forecasting.exponential_smoothing_forecasting import (
execute,
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
FROM_DATE = "2026-06-01"
TO_DATE = "2026-08-31"
SMOOTHING_CONSTANT = 0.5
class TestExponentialSmoothingForecasting(ERPNextTestSuite):
"""Drive real submitted Sales Orders and assert the report buckets the ordered
quantities into the correct historical periods and produces a forecast."""
def setUp(self):
# The forecast query has no lower date bound, so it would pick up any committed
# Sales Order for the item. A uniquely-named item keeps the buckets scoped to
# just this test's orders.
self.item = make_item(properties={"is_stock_item": 1}).name
def test_monthly_qty_forecast_from_sales_orders(self):
# Historical demand: distinct calendar months strictly before FROM_DATE.
# Monthly period keys are derived from the period's last day (e.g. "mar_2026").
history = {"mar_2026": 7, "apr_2026": 4, "may_2026": 9}
self.create_sales_orders(
{
"2026-03-15": history["mar_2026"],
"2026-04-15": history["apr_2026"],
"2026-05-15": history["may_2026"],
}
)
columns, row = self.run_report()
fields = {col["fieldname"] for col in columns}
# For Monthly periodicity only future periods are exposed as columns, each as a
# forecast_ field. Historical demand lives in the row data (keyed by month) but is
# not surfaced as its own column.
self.assertIn("forecast_jun_2026", fields, "expected future forecast column")
self.assertNotIn("jun_2026", fields, "future period must not expose raw demand column")
self.assertNotIn("mar_2026", fields, "historical month is not a Monthly report column")
# Historical buckets must exactly reflect the ordered quantities.
for key, qty in history.items():
self.assertEqual(flt(row.get(key)), flt(qty), f"bucket {key} mismatch")
# The forecast seeds at the average of the non-zero historical months and then
# smooths through them in order: F = F + a*(actual - F). Asserting the exact
# analytical value pins the smoothing formula (Jun 2026 works out to ~7.2083).
expected_avg = sum(history.values()) / len(history)
self.assertAlmostEqual(flt(row.get("avg")), expected_avg, places=6)
forecast = expected_avg
for month in ("mar_2026", "apr_2026", "may_2026"):
forecast = forecast + SMOOTHING_CONSTANT * (history[month] - forecast)
self.assertAlmostEqual(flt(row.get("forecast_jun_2026")), forecast, places=6)
def test_ignores_documents_outside_range_and_other_docstatus(self):
self.create_sales_orders({"2026-05-10": 6})
# A draft SO and a future-dated SO must not contribute to historical demand.
make_sales_order(item_code=self.item, qty=100, transaction_date="2026-05-20", do_not_submit=True)
make_sales_order(item_code=self.item, qty=100, transaction_date=FROM_DATE)
_columns, row = self.run_report()
self.assertEqual(flt(row.get("may_2026")), 6.0)
def create_sales_orders(self, date_to_qty):
for transaction_date, qty in date_to_qty.items():
make_sales_order(item_code=self.item, qty=qty, transaction_date=transaction_date)
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"based_on_document": "Sales Order",
"based_on_field": "Qty",
"no_of_years": 3,
"periodicity": "Monthly",
"from_date": FROM_DATE,
"to_date": TO_DATE,
"smoothing_constant": SMOOTHING_CONSTANT,
"item_code": self.item,
}
)
filters.update(extra)
columns, data = execute(filters)[:2]
item_row = next(
(r for r in data if r.get("item_code") == self.item),
None,
)
self.assertIsNotNone(item_row, f"{self.item} row missing from report output")
return columns, item_row

View File

@@ -0,0 +1,87 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.job_card_summary.job_card_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestJobCardSummary(ERPNextTestSuite):
def setUp(self):
# `_Test FG Item 2` has a default active BOM with operations, so submitting a
# Work Order for it auto-creates Job Cards (one per operation).
self.work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
self.job_cards = frappe.get_all(
"Job Card",
filters={"work_order": self.work_order.name},
fields=["name", "operation", "workstation", "production_item", "status"],
)
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": add_days(today(), -1),
"to_date": add_days(today(), 1),
}
)
filters.update(extra)
return execute(filters)[1]
def rows_for_work_order(self, rows):
return [row for row in rows if row.get("work_order") == self.work_order.name]
def test_job_cards_are_listed(self):
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
rows = self.rows_for_work_order(self.run_report())
self.assertEqual(len(rows), len(self.job_cards))
reported_names = {row.get("name") for row in rows}
self.assertEqual(reported_names, {jc.name for jc in self.job_cards})
# Fresh (unsubmitted) job cards are reported as Open, and each row carries the
# operation / workstation / production item pulled from the Job Card.
for jc in self.job_cards:
row = next(row for row in rows if row.get("name") == jc.name)
self.assertEqual(row.get("status"), "Open")
self.assertEqual(row.get("operation"), jc.operation)
self.assertEqual(row.get("workstation"), jc.workstation)
self.assertEqual(row.get("production_item"), jc.production_item)
def test_operation_filter_scopes_rows(self):
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
operation = self.job_cards[0].operation
matching = {jc.name for jc in self.job_cards if jc.operation == operation}
rows = self.rows_for_work_order(self.run_report(operation=operation))
self.assertEqual({row.get("name") for row in rows}, matching)
def test_status_filter(self):
self.assertTrue(self.job_cards, "Work Order did not produce any Job Cards")
# The status filter matches the Job Card's *stored* status, so derive the
# expected set from that rather than assuming fresh cards are literally "Open".
stored_status = self.job_cards[0].status
expected = {jc.name for jc in self.job_cards if jc.status == stored_status}
rows = self.rows_for_work_order(self.run_report(status=stored_status))
self.assertEqual({row.get("name") for row in rows}, expected)
# any non-completed card is displayed as "Open" regardless of its stored status
for row in rows:
self.assertEqual(row.get("status"), "Open")
# None of the freshly created job cards are Completed yet.
completed_rows = self.rows_for_work_order(self.run_report(status="Completed"))
self.assertEqual(completed_rows, [])
def test_date_filter_excludes_out_of_range(self):
# Job Card posting_date defaults to today; a past-only window should exclude them.
rows = self.rows_for_work_order(
self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
)
self.assertEqual(rows, [])

View File

@@ -50,11 +50,11 @@ def get_data(filters: Filters) -> Data:
.groupby(se.work_order)
)
if "item" in filters:
query.where(wo.production_item == filters.item)
if filters.get("item"):
query = query.where(wo.production_item == filters.item)
if "work_order" in filters:
query.where(wo.name == filters.work_order)
if filters.get("work_order"):
query = query.where(wo.name == filters.work_order)
data = query.run(as_dict=True)

View File

@@ -0,0 +1,114 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import nowdate
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.process_loss_report.process_loss_report import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessLossReport(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": nowdate(),
"to_date": nowdate(),
}
)
filters.update(extra)
return execute(filters)[1]
def find_row(self, data, work_order):
for row in data:
if row.get("name") == work_order:
return row
return None
def make_manufactured_work_order(self, planned_qty, produced_qty):
"""Create a submitted WO and manufacture `produced_qty` of `planned_qty`.
The difference is booked as process loss on the Manufacture stock entry,
which propagates to the work order's `process_loss_qty`.
"""
wo_order = make_wo_order_test_record(production_item="_Test FG Item", qty=planned_qty)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=100, basic_rate=100
)
transfer = frappe.get_doc(
make_stock_entry(wo_order.name, "Material Transfer for Manufacture", planned_qty)
)
for d in transfer.get("items"):
d.s_warehouse = "Stores - _TC"
transfer.insert()
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", planned_qty))
# Reduce the finished good qty below fg_completed_qty so the difference is
# recorded as process loss.
process_loss_qty = planned_qty - produced_qty
if process_loss_qty:
for d in manufacture.get("items"):
if d.is_finished_item:
d.qty = produced_qty
d.transfer_qty = produced_qty * (d.conversion_factor or 1)
manufacture.insert()
manufacture.submit()
wo_order.reload()
return wo_order
def test_work_order_with_process_loss_is_listed(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
self.assertEqual(wo_order.process_loss_qty, 1)
self.assertEqual(wo_order.produced_qty, 4)
data = self.run_report()
row = self.find_row(data, wo_order.name)
self.assertIsNotNone(row, "Work order with process loss should appear in the report")
self.assertEqual(row.production_item, "_Test FG Item")
self.assertEqual(row.qty_to_manufacture, 5)
self.assertEqual(row.produced_qty, 4)
self.assertEqual(row.process_loss_qty, 1)
# total_pl_value = process_loss_qty * (total_fg_value / qty_to_manufacture)
expected_pl_value = row.process_loss_qty * (row.total_fg_value / row.qty_to_manufacture)
self.assertAlmostEqual(row.total_pl_value, expected_pl_value)
self.assertGreater(row.total_pl_value, 0)
def test_work_order_without_process_loss_is_not_listed(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=5)
self.assertEqual(wo_order.process_loss_qty, 0)
self.assertEqual(wo_order.produced_qty, 5)
data = self.run_report()
self.assertIsNone(
self.find_row(data, wo_order.name),
"Work order that produced the full planned qty should not appear (no loss)",
)
def test_item_filter_scopes_rows(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
# a matching production item includes the row, a non-matching one excludes it
self.assertIsNotNone(self.find_row(self.run_report(item="_Test FG Item"), wo_order.name))
self.assertIsNone(self.find_row(self.run_report(item="_Test FG Item 2"), wo_order.name))
def test_work_order_filter_scopes_rows(self):
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
# the matching work order is included, a different work order name is excluded
self.assertIsNotNone(self.find_row(self.run_report(work_order=wo_order.name), wo_order.name))
self.assertIsNone(self.find_row(self.run_report(work_order=f"{wo_order.name}-XX"), wo_order.name))

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _, scrub
from frappe.utils import getdate, today
from frappe.utils import get_datetime, getdate, today
from erpnext.stock.report.stock_analytics.stock_analytics import (
get_period,
@@ -31,7 +31,9 @@ def get_columns(period_columns):
def get_work_orders(filters):
from_date = filters.get("from_date")
to_date = filters.get("to_date")
# `creation` and `actual_end_date` are datetime columns, so a bare date upper
# bound would coerce to midnight and drop records created later on the last day.
to_date = get_datetime(filters.get("to_date")).replace(hour=23, minute=59, second=59)
WorkOrder = frappe.qb.DocType("Work Order")

View File

@@ -0,0 +1,66 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import _
from frappe.utils import get_first_day, get_last_day, today
from erpnext.tests.utils import ERPNextTestSuite
class TestProductionAnalytics(ERPNextTestSuite):
def run_report(self, **extra):
from erpnext.manufacturing.report.production_analytics.production_analytics import execute
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": get_first_day(today()),
"to_date": get_last_day(today()),
"range": "Monthly",
}
)
filters.update(extra)
columns, data, _msg, _chart = execute(filters)
return columns, data
def get_period_count(self, columns, data, status, period_label):
"""Return the count for a status row under the period column resolved by label."""
period_fieldname = next(col["fieldname"] for col in columns if col.get("label") == period_label)
# the report stores the translated status label, so translate before matching
row = next(row for row in data if row["status"] == _(status))
return row[period_fieldname]
def test_submitted_work_order_increments_status_count(self):
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
# pin the reporting window once so both runs use the same period even if the
# test happens to straddle a month boundary
from_date, to_date = get_first_day(today()), get_last_day(today())
# The current month is the period a newly created Work Order falls into (bucketed by creation date).
cols_before, data_before = self.run_report(from_date=from_date, to_date=to_date)
period_label = cols_before[-1]["label"]
before = self.get_period_count(cols_before, data_before, "Not Started", period_label)
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=10, company="_Test Company")
self.assertEqual(wo.docstatus, 1)
# A freshly submitted Work Order with no material transfer has status "Not Started".
self.assertEqual(wo.status, "Not Started")
cols_after, data_after = self.run_report(from_date=from_date, to_date=to_date)
after = self.get_period_count(cols_after, data_after, "Not Started", period_label)
self.assertEqual(after, before + 1)
def test_report_shape(self):
columns, data = self.run_report()
# First column is the Status column, followed by one column per period.
self.assertEqual(columns[0]["fieldname"], "status")
self.assertGreaterEqual(len(columns), 2)
# One row per known Work Order status.
statuses = {row["status"] for row in data}
for status in ("Not Started", "Overdue", "Pending", "Completed", "Closed", "Stopped"):
self.assertIn(_(status), statuses)

View File

@@ -42,7 +42,9 @@ def get_production_plan_item_details(filters, data, order_details):
order_qty = row.planned_qty
total_produced_qty = 0.0
pending_qty = 0.0
# default to the full planned qty so a plan without any work order still
# reports everything as pending rather than a misleading zero
pending_qty = flt(order_qty)
for work_order in work_orders:
produced_qty = flt(order_details.get((work_order, row.item_code), {}).get("produced_qty", 0))
pending_qty = flt(order_qty) - produced_qty

View File

@@ -0,0 +1,130 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.manufacturing.doctype.production_plan.test_production_plan import create_production_plan
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_se_from_wo
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.production_plan_summary.production_plan_summary import execute
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProductionPlanSummary(ERPNextTestSuite):
def run_report(self, production_plan):
filters = frappe._dict({"production_plan": production_plan})
return execute(filters)[1]
def make_plan(self, planned_qty=2):
return create_production_plan(
item_code="_Test FG Item",
planned_qty=planned_qty,
skip_getting_mr_items=1,
)
def make_submitted_work_order(self, plan, qty):
wo = make_wo_order_test_record(
item_code="_Test FG Item",
qty=qty,
company=plan.company,
wip_warehouse="Work In Progress - _TC",
fg_warehouse="Finished Goods - _TC",
skip_transfer=1,
use_multi_level_bom=1,
do_not_submit=True,
)
wo.production_plan = plan.name
wo.production_plan_item = plan.po_items[0].name
wo.submit()
return wo
def stock_required_materials(self, wo):
# make sure every raw material is available in its source warehouse before manufacturing,
# otherwise a clean database raises NegativeStockError
for item in wo.required_items:
make_stock_entry(
item_code=item.item_code,
to_warehouse=item.source_warehouse or "_Test Warehouse - _TC",
qty=item.required_qty + 10,
rate=100,
)
def get_work_order_row(self, data, item_code):
for row in data:
if row.get("item_code") == item_code and row.get("document_type") == "Work Order":
return row
return None
def get_summary_row(self, data, item_code):
for row in data:
if row.get("item_code") == item_code and not row.get("document_type"):
return row
return None
def test_summary_without_work_order(self):
"""A submitted plan with no work order still yields a summary row for the planned item."""
plan = self.make_plan(planned_qty=2)
data = self.run_report(plan.name)
summary = self.get_summary_row(data, "_Test FG Item")
self.assertIsNotNone(summary)
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 0)
# nothing produced yet, so the whole planned qty is pending
self.assertEqual(summary.get("pending_qty"), 2)
self.assertIsNone(self.get_work_order_row(data, "_Test FG Item"))
def test_summary_with_pending_work_order(self):
"""An unproduced work order shows full planned qty as pending."""
plan = self.make_plan(planned_qty=2)
wo = self.make_submitted_work_order(plan, qty=2)
data = self.run_report(plan.name)
wo_row = self.get_work_order_row(data, "_Test FG Item")
self.assertIsNotNone(wo_row)
self.assertEqual(wo_row.get("document_name"), wo.name)
self.assertEqual(wo_row.get("qty"), 2)
self.assertEqual(wo_row.get("produced_qty"), 0)
self.assertEqual(wo_row.get("pending_qty"), 2)
summary = self.get_summary_row(data, "_Test FG Item")
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 0)
def test_summary_reflects_produced_qty(self):
"""Producing part of the work order updates produced and pending quantities."""
plan = self.make_plan(planned_qty=2)
wo = self.make_submitted_work_order(plan, qty=2)
self.stock_required_materials(wo)
se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
se.submit()
data = self.run_report(plan.name)
wo_row = self.get_work_order_row(data, "_Test FG Item")
self.assertEqual(wo_row.get("document_name"), wo.name)
self.assertEqual(wo_row.get("produced_qty"), 1)
self.assertEqual(wo_row.get("pending_qty"), 1)
summary = self.get_summary_row(data, "_Test FG Item")
self.assertEqual(summary.get("qty"), 2)
self.assertEqual(summary.get("produced_qty"), 1)
self.assertEqual(summary.get("pending_qty"), 1)
def test_summary_scoped_to_its_own_plan(self):
"""Each plan's report only reports its own work order documents."""
plan_a = self.make_plan(planned_qty=2)
wo_a = self.make_submitted_work_order(plan_a, qty=2)
plan_b = self.make_plan(planned_qty=3)
wo_b = self.make_submitted_work_order(plan_b, qty=3)
data_a = self.run_report(plan_a.name)
document_names = {row.get("document_name") for row in data_a if row.get("document_name")}
self.assertIn(wo_a.name, document_names)
self.assertNotIn(wo_b.name, document_names)

View File

@@ -0,0 +1,81 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.manufacturing.report.quality_inspection_summary.quality_inspection_summary import execute
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection,
make_minimal_job_card,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestQualityInspectionSummary(ERPNextTestSuite):
def setUp(self):
super().setUp()
create_item("_Test Item")
self.job_card = make_minimal_job_card(production_item="_Test Item")
self.qi = create_quality_inspection(
item_code="_Test Item",
reference_type="Job Card",
reference_name=self.job_card,
status="Accepted",
)
def run_report(self, **extra):
filters = frappe._dict(extra)
return execute(filters)[1]
def _rows_for_qi(self, data):
return [row for row in data if row.get("name") == self.qi.name]
def test_appears_in_date_range(self):
data = self.run_report(from_date=add_days(nowdate(), -1), to_date=add_days(nowdate(), 1))
rows = self._rows_for_qi(data)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row["status"], "Accepted")
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["reference_type"], "Job Card")
self.assertEqual(row["reference_name"], self.job_card)
def test_excluded_outside_date_range(self):
data = self.run_report(from_date=add_days(nowdate(), -10), to_date=add_days(nowdate(), -5))
self.assertEqual(self._rows_for_qi(data), [])
def test_status_filter_includes_matching(self):
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
status=["Accepted"],
)
self.assertEqual(len(self._rows_for_qi(data)), 1)
def test_status_filter_excludes_non_matching(self):
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
status=["Rejected"],
)
self.assertEqual(self._rows_for_qi(data), [])
def test_item_code_filter_includes_matching(self):
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
item_code=["_Test Item"],
)
self.assertEqual(len(self._rows_for_qi(data)), 1)
def test_item_code_filter_excludes_other_item(self):
other_item = frappe.generate_hash(length=10)
data = self.run_report(
from_date=add_days(nowdate(), -1),
to_date=add_days(nowdate(), 1),
item_code=[other_item],
)
self.assertEqual(self._rows_for_qi(data), [])

View File

@@ -0,0 +1,111 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, nowdate
from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.work_order_consumed_materials.work_order_consumed_materials import execute
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestWorkOrderConsumedMaterials(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": add_days(nowdate(), -1),
"to_date": add_days(nowdate(), 1),
}
)
filters.update(extra)
return execute(filters)[1]
def make_manufactured_work_order(self, qty=2):
"""Create a submitted WO, stock its raw materials, transfer and fully manufacture it."""
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=qty, company="_Test Company")
for item in wo.required_items:
test_stock_entry.make_stock_entry(
item_code=item.item_code,
target=wo.wip_warehouse,
qty=item.required_qty,
basic_rate=100,
)
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", qty))
transfer.insert()
transfer.submit()
manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", qty))
manufacture.insert()
manufacture.submit()
wo.reload()
return wo
def get_wo_rows(self, data, work_order):
"""The report blanks parent fields after the first raw-material row, so match by raw
material's parent work order instead of the (blanked) `name` column."""
return [row for row in data if row.get("parent") == work_order]
def test_consumed_materials_reported_after_manufacture(self):
wo = self.make_manufactured_work_order(qty=2)
# fully producing the WO consumes exactly the required quantity of each raw material
self.assertEqual(wo.produced_qty, 2)
data = self.run_report()
rows = self.get_wo_rows(data, wo.name)
self.assertEqual(len(rows), len(wo.required_items))
# pair rows to required items by sorting rather than a dict keyed on item code, so
# a BOM with two lines for the same component wouldn't silently collapse to one row
rows_sorted = sorted(rows, key=lambda r: (r["raw_material_item_code"], r["required_qty"]))
items_sorted = sorted(wo.required_items, key=lambda i: (i.item_code, i.required_qty))
for row, item in zip(rows_sorted, items_sorted, strict=True):
self.assertEqual(row["raw_material_item_code"], item.item_code)
self.assertEqual(row["required_qty"], item.required_qty)
self.assertEqual(row["transferred_qty"], item.required_qty)
self.assertEqual(row["consumed_qty"], item.required_qty)
# no over-consumption in a clean full manufacture
self.assertEqual(row["extra_consumed_qty"], 0.0)
self.assertEqual(row["returned_qty"], 0.0)
# parent columns are populated on the first row only
first = rows[0]
self.assertEqual(first["status"], wo.status)
self.assertEqual(first["production_item"], "_Test FG Item")
self.assertEqual(first["qty"], 2)
self.assertEqual(first["produced_qty"], 2)
def test_work_order_filter_scopes_output(self):
wo = self.make_manufactured_work_order(qty=1)
data = self.run_report(name=wo.name)
parents = {row.get("parent") for row in data}
self.assertEqual(parents, {wo.name})
self.assertTrue(data)
def test_draft_work_order_is_excluded(self):
# report only lists WOs in status In Process / Completed / Stopped
draft = make_wo_order_test_record(
production_item="_Test FG Item", qty=1, company="_Test Company", do_not_submit=True
)
data = self.run_report()
self.assertNotIn(draft.name, {row.get("parent") for row in data})
def test_date_range_filter_excludes_work_order(self):
wo = self.make_manufactured_work_order(qty=1)
# positive anchor: the WO shows up within the default (current) window
self.assertIn(wo.name, {row.get("parent") for row in self.run_report()})
# a window that ends before the WO was created must not include it
data = self.run_report(from_date=add_days(nowdate(), -10), to_date=add_days(nowdate(), -5))
self.assertNotIn(wo.name, {row.get("parent") for row in data})

View File

@@ -0,0 +1,60 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.report.work_order_summary.work_order_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestWorkOrderSummary(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict(
{
"company": "_Test Company",
"from_date": add_days(today(), -1),
"to_date": today(),
}
)
filters.update(extra)
return execute(filters)[1]
def test_work_order_appears_with_expected_fields(self):
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=10, company="_Test Company")
rows = {row["name"]: row for row in self.run_report()}
self.assertIn(wo.name, rows)
row = rows[wo.name]
self.assertEqual(row["production_item"], "_Test FG Item")
self.assertEqual(row["qty"], 10)
self.assertEqual(row["produced_qty"], 0)
self.assertEqual(row["status"], "Not Started")
def test_status_filter_excludes_other_statuses(self):
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=10, company="_Test Company")
self.assertEqual(wo.status, "Not Started")
# A "Completed" filter must not return a "Not Started" work order.
names = {row["name"] for row in self.run_report(status="Completed")}
self.assertNotIn(wo.name, names)
# The matching status still returns it.
names = {row["name"] for row in self.run_report(status="Not Started")}
self.assertIn(wo.name, names)
def test_date_range_excludes_work_order_outside_window(self):
wo = make_wo_order_test_record(production_item="_Test FG Item", qty=10, company="_Test Company")
# A window entirely in the past cannot contain a WO created today.
names = {
row["name"]
for row in self.run_report(from_date=add_days(today(), -10), to_date=add_days(today(), -5))
}
self.assertNotIn(wo.name, names)
# A window that includes today does contain it.
names = {row["name"] for row in self.run_report()}
self.assertIn(wo.name, names)

View File

@@ -432,9 +432,10 @@
"type": "Link"
}
],
"modified": "2026-05-05 11:00:26.131777",
"modified": "2026-06-14 13:44:07.420267",
"modified_by": "Administrator",
"module": "Manufacturing",
"module_onboarding": "Manufacturing Onboarding",
"name": "Manufacturing",
"number_cards": [
{
@@ -458,6 +459,465 @@
"roles": [],
"sequence_id": 8.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Manufacturing",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Manufacturing",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "list-tree",
"indent": 0,
"keep_closed": 0,
"label": "BOM",
"link_to": "BOM",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "factory",
"indent": 0,
"keep_closed": 0,
"label": "Work Order",
"link_to": "Work Order",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "person-standing",
"indent": 0,
"keep_closed": 0,
"label": "Job Card",
"link_to": "Job Card",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"indent": 0,
"keep_closed": 0,
"label": "Stock Entry",
"link_to": "Stock Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"indent": 1,
"keep_closed": 1,
"label": "Material Planning",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item Lead Time",
"link_to": "Item Lead Time",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Production Plan",
"link_to": "Production Plan",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Forecasting",
"link_to": "Exponential Smoothing Forecasting",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Master Production Schedule",
"link_to": "Master Production Schedule",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Sales Forecast",
"link_to": "Sales Forecast",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
"link_to": "Production Planning Report",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "tool",
"indent": 1,
"keep_closed": 1,
"label": "Tools",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "BOM Creator",
"link_to": "BOM Creator",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "BOM Update Tool",
"link_to": "BOM Update Tool",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "BOM Comparison Tool",
"link_to": "bom-comparison-tool",
"link_type": "Page",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Entry",
"link_to": "Downtime Entry",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "notepad-text",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Production Planning Report",
"link_to": "Production Planning Report",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Summary",
"link_to": "Work Order Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection Summary",
"link_to": "Quality Inspection Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Downtime Analysis",
"link_to": "Downtime Analysis",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Job Card Summary",
"link_to": "Job Card Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "BOM Search",
"link_to": "BOM Search",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Production Analytics",
"link_to": "Production Analytics",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "BOM Operations Time",
"link_to": "BOM Operations Time",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Work Order Consumed Materials",
"link_to": "Work Order Consumed Materials",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Item",
"link_to": "Item",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Warehouse",
"link_to": "Warehouse",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Operation",
"link_to": "Operation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Workstation",
"link_to": "Workstation",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Workstation Type",
"link_to": "Workstation Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Plant Floor",
"link_to": "Plant Floor",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Routing",
"link_to": "Routing",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Manufacturing Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Manufacturing",
"type": "Workspace"
}

View File

@@ -492,3 +492,4 @@ erpnext.patches.v16_0.rename_subscription_billing_period_fields
erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb
erpnext.patches.v16_0.set_default_close_opportunity_after_days
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
erpnext.patches.v16_0.backfill_pick_list_transferred_qty

View File

@@ -0,0 +1,58 @@
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import flt
def execute():
StockEntry = frappe.qb.DocType("Stock Entry")
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
pick_lists = (
frappe.qb.from_(StockEntry)
.select(StockEntry.pick_list)
.distinct()
.where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1))
).run(pluck=True)
if not pick_lists:
return
rows = (
frappe.qb.from_(StockEntryDetail)
.join(StockEntry)
.on(StockEntryDetail.parent == StockEntry.name)
.select(
StockEntry.pick_list,
StockEntryDetail.item_code,
StockEntryDetail.s_warehouse,
Sum(StockEntryDetail.transfer_qty).as_("qty"),
)
.where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1))
.groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse)
).run(as_dict=True)
transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows}
items = frappe.get_all(
"Pick List Item",
filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)},
fields=["name", "parent", "item_code", "warehouse", "picked_qty"],
order_by="idx",
)
updates = {}
for row in items:
key = (row.parent, row.item_code, row.warehouse)
available = transferred.get(key, 0)
if available <= 0:
continue
qty = min(flt(row.picked_qty), available)
transferred[key] = available - qty
updates[row.name] = {"transferred_qty": qty}
if not updates:
return
frappe.db.auto_commit_on_many_writes = True
frappe.db.bulk_update("Pick List Item", updates)
frappe.db.auto_commit_on_many_writes = False

View File

@@ -34,6 +34,7 @@ def complete_onboarding_steps_if_record_exists(steps):
if (
step.action == "Create Entry"
and step.reference_document
and frappe.db.exists("DocType", step.reference_document)
and frappe.get_all(step.reference_document, limit=1)
):
frappe.db.set_value("Onboarding Step", step.name, "is_complete", 1, update_modified=False)

View File

@@ -9,7 +9,7 @@ from frappe import _, throw
from frappe.desk.form.assign_to import clear, close_all_assignments
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Max, Min, Sum
from frappe.utils import add_days, add_to_date, cstr, date_diff, flt, get_link_to_form, getdate, today
from frappe.utils import add_days, add_to_date, date_diff, flt, get_link_to_form, getdate, today
from frappe.utils.data import format_date
from frappe.utils.nestedset import NestedSet
@@ -247,25 +247,32 @@ class Task(NestedSet):
def check_recursion(self):
if self.flags.ignore_recursion_check:
return
check_list = [["task", "parent"], ["parent", "task"]]
for d in check_list:
task_list, count = [self.name], 0
while len(task_list) > count:
tasks = frappe.get_all(
"Task Depends On",
filters={d[1]: cstr(task_list[count])},
fields=[d[0]],
as_list=True,
)
count = count + 1
for b in tasks:
if b[0] == self.name:
frappe.throw(_("Circular Reference Error"), CircularReferenceError)
if b[0]:
task_list.append(b[0])
# "Task Depends On" is a directed edge (parent depends on `task`); a cycle exists if this
# task is reachable from itself along either direction. One recursive CTE per direction
# fetches the whole reachable set in a single query -- UNION makes it cycle-safe at any
# depth, so unlike the old per-node BFS it needs no arbitrary depth cap.
for select_field, filter_field in (("task", "parent"), ("parent", "task")):
if self._reaches_self(select_field, filter_field):
frappe.throw(_("Circular Reference Error"), CircularReferenceError)
if count == 15:
break
def _reaches_self(self, select_field: str, filter_field: str) -> bool:
depends_on = frappe.qb.DocType("Task Depends On")
tree = frappe.qb.Table("dependency_tree")
seed = (
frappe.qb.from_(depends_on)
.select(depends_on[select_field].as_("node"))
.where(depends_on[filter_field] == self.name)
)
recursion = (
frappe.qb.from_(depends_on)
.join(tree)
.on(depends_on[filter_field] == tree.node)
.select(depends_on[select_field])
)
reachable = (
frappe.qb.with_(seed + recursion, "dependency_tree", recursive=True).from_(tree).select(tree.node)
).run(pluck=True)
return self.name in reachable
def reschedule_dependent_tasks(self):
end_date = self.exp_end_date or self.act_end_date

View File

@@ -0,0 +1,65 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import _
from erpnext.projects.report.project_summary.project_summary import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectSummary(ERPNextTestSuite):
"""Lists projects with their total / completed / overdue task counts."""
def make_project(self):
return frappe.get_doc(
{
"doctype": "Project",
"project_name": f"_Test PS {frappe.generate_hash(length=6)}",
"company": "_Test Company",
}
).insert()
def make_task(self, project, status="Open"):
task = frappe.get_doc(
{
"doctype": "Task",
"subject": f"Task {frappe.generate_hash(length=6)}",
"project": project.name,
}
).insert()
if status != "Open":
# set the status directly; the report counts tasks by their stored status
frappe.db.set_value("Task", task.name, "status", status)
return task
def run_report(self, project):
return execute(frappe._dict({"name": project.name}))
def project_row(self, project):
_columns, data, *_rest = self.run_report(project)
return next((r for r in data if r["name"] == project.name), None)
def test_task_counts(self):
project = self.make_project()
self.make_task(project, "Completed")
self.make_task(project, "Completed")
self.make_task(project, "Open")
self.make_task(project, "Overdue")
row = self.project_row(project)
self.assertIsNotNone(row, "Project missing from report")
self.assertEqual(row["total_tasks"], 4)
self.assertEqual(row["completed_tasks"], 2)
self.assertEqual(row["overdue_tasks"], 1)
def test_report_summary_totals(self):
project = self.make_project()
self.make_task(project, "Completed")
self.make_task(project, "Open")
_columns, _data, _message, _chart, report_summary = self.run_report(project)
summary = {s["label"]: s["value"] for s in report_summary}
self.assertEqual(summary[_("Total Tasks")], 2)
self.assertEqual(summary[_("Completed Tasks")], 1)
self.assertEqual(summary[_("Overdue Tasks")], 0)

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet
from erpnext.projects.report.timesheet_billing_summary.timesheet_billing_summary import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestTimesheetBillingSummary(ERPNextTestSuite):
"""Lists submitted Timesheet Detail rows with working/billing hours and amount,
optionally grouped by date/project/employee."""
def setUp(self):
self.employee = make_employee("timesheet_billing@example.com", company="_Test Company")
self.project = frappe.get_doc(
{
"doctype": "Project",
"project_name": f"_Test TBS {frappe.generate_hash(length=6)}",
"company": "_Test Company",
}
).insert()
def make_ts(self, is_billable=1):
return make_timesheet(
self.employee, simulate=True, is_billable=is_billable, project=self.project.name
)
def run_report(self, **extra):
filters = frappe._dict({"company": "_Test Company", "employee": self.employee})
filters.update(extra)
return execute(filters)[1]
def test_billable_timesheet_row(self):
ts = self.make_ts(is_billable=1)
detail = ts.time_logs[0]
rows = [r for r in self.run_report() if r.get("timesheet") == ts.name]
self.assertTrue(rows, "Timesheet missing from report")
row = rows[0]
self.assertEqual(row["hours"], 2)
self.assertEqual(row["billing_hours"], detail.billing_hours)
self.assertEqual(row["billing_amount"], detail.billing_amount)
self.assertEqual(row["project"], self.project.name)
def test_group_by_project_sums_hours(self):
self.make_ts(is_billable=1)
data = self.run_report(group_by="project")
group_rows = [r for r in data if r.get("is_group") and r.get("project") == self.project.name]
self.assertTrue(group_rows, "Grouped project row missing")
self.assertEqual(group_rows[0]["hours"], 2)
def test_draft_excluded_unless_requested(self):
ts = make_timesheet(
self.employee, simulate=True, is_billable=1, project=self.project.name, do_not_submit=True
)
# submitted-only by default: the draft timesheet is absent
self.assertNotIn(ts.name, {r.get("timesheet") for r in self.run_report()})
# ... but included when draft timesheets are requested
self.assertIn(ts.name, {r.get("timesheet") for r in self.run_report(include_draft_timesheets=1)})

View File

@@ -18,6 +18,14 @@
"is_hidden": 0,
"label": "Projects",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Projects",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
@@ -37,6 +45,28 @@
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Project",
"link_count": 0,
"link_to": "Project",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Task",
"link_count": 0,
"link_to": "Task",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
@@ -59,6 +89,17 @@
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Project Template",
"link_count": 0,
"link_to": "Project Template",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
@@ -70,6 +111,28 @@
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Project Type",
"link_count": 0,
"link_to": "Project Type",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Project",
"hidden": 0,
"is_query_report": 0,
"label": "Project Update",
"link_count": 0,
"link_to": "Project Update",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Project",
"hidden": 0,
@@ -89,6 +152,14 @@
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Time Tracking",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
@@ -100,6 +171,28 @@
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Timesheet",
"link_count": 0,
"link_to": "Timesheet",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Activity Type",
"link_count": 0,
"link_to": "Activity Type",
"link_type": "DocType",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
@@ -122,6 +215,17 @@
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Activity Type",
"hidden": 0,
"is_query_report": 0,
"label": "Activity Cost",
"link_count": 0,
"link_to": "Activity Cost",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@@ -130,6 +234,25 @@
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Reports",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "Timesheet",
"hidden": 0,
"is_query_report": 1,
"label": "Daily Timesheet Summary",
"link_count": 0,
"link_to": "Daily Timesheet Summary",
"link_type": "Report",
"onboard": 1,
"type": "Link"
},
{
"dependencies": "Timesheet",
"hidden": 0,
@@ -152,6 +275,17 @@
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Project",
"hidden": 0,
"is_query_report": 1,
"label": "Project wise Stock Tracking",
"link_count": 0,
"link_to": "Project wise Stock Tracking",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Project",
"hidden": 0,
@@ -163,6 +297,28 @@
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Project",
"hidden": 0,
"is_query_report": 1,
"label": "Timesheet Billing Summary",
"link_count": 0,
"link_to": "Timesheet Billing Summary",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Task",
"hidden": 0,
"is_query_report": 1,
"label": "Delayed Tasks Summary",
"link_count": 0,
"link_to": "Delayed Tasks Summary",
"link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "Task",
"hidden": 0,
@@ -182,6 +338,24 @@
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Settings",
"link_count": 1,
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Projects Settings",
"link_count": 0,
"link_to": "Projects Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@@ -193,9 +367,10 @@
"type": "Link"
}
],
"modified": "2026-01-02 17:26:44.644507",
"modified": "2026-07-01 13:20:50.651608",
"modified_by": "Administrator",
"module": "Projects",
"module_onboarding": "Projects Onboarding",
"name": "Projects",
"number_cards": [
{
@@ -219,6 +394,250 @@
"roles": [],
"sequence_id": 11.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Projects",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
"link_to": "Project",
"link_type": "Dashboard",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "projects",
"indent": 0,
"keep_closed": 0,
"label": "Project",
"link_to": "Project",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "list-todo",
"indent": 0,
"keep_closed": 0,
"label": "Task",
"link_to": "Task",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "calendar-clock",
"indent": 0,
"keep_closed": 0,
"label": "Timesheet",
"link_to": "Timesheet",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Activity Type",
"link_to": "Activity Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Activity Cost",
"link_to": "Activity Cost",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Project Template",
"link_to": "Project Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Project Type",
"link_to": "Project Type",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Project Update",
"link_to": "Project Update",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "sheet",
"indent": 1,
"keep_closed": 1,
"label": "Reports",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"icon": "",
"indent": 0,
"keep_closed": 0,
"label": "Project Summary",
"link_to": "Project Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Daily Timesheet Summary",
"link_to": "Daily Timesheet Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Timesheet Billing Summary",
"link_to": "Timesheet Billing Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Project wise Stock Tracking",
"link_to": "Project wise Stock Tracking",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"default_workspace": 0,
"indent": 0,
"keep_closed": 0,
"label": "Delayed Tasks Summary",
"link_to": "Delayed Tasks Summary",
"link_type": "Report",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "settings",
"indent": 0,
"keep_closed": 0,
"label": "Settings",
"link_to": "Projects Settings",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Projects",
"type": "Workspace"
}

View File

@@ -161,7 +161,7 @@
"type": "Link"
}
],
"modified": "2026-01-02 17:32:47.522875",
"modified": "2026-06-14 13:44:07.920643",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality",
@@ -174,6 +174,161 @@
"roles": [],
"sequence_id": 9.0,
"shortcuts": [],
"sidebar_items": [
{
"child": 0,
"collapsible": 1,
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Quality",
"link_type": "Workspace",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "inspection-panel",
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection",
"link_to": "Quality Inspection",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "goal",
"indent": 0,
"keep_closed": 0,
"label": "Quality Goal",
"link_to": "Quality Goal",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "review",
"indent": 0,
"keep_closed": 0,
"label": "Quality Review",
"link_to": "Quality Review",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "square-activity",
"indent": 0,
"keep_closed": 0,
"label": "Quality Action",
"link_to": "Quality Action",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "grid-2x2-check",
"indent": 0,
"keep_closed": 0,
"label": "Non Conformance",
"link_to": "Non Conformance",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "thumbs-up",
"indent": 0,
"keep_closed": 0,
"label": "Quality Feedback",
"link_to": "Quality Feedback",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "users",
"indent": 0,
"keep_closed": 0,
"label": "Quality Meeting",
"link_to": "Quality Meeting",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"icon": "database",
"indent": 1,
"keep_closed": 1,
"label": "Setup",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Section Break"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Quality Procedure",
"link_to": "Quality Procedure",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Feedback Template",
"link_to": "Quality Feedback Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
},
{
"child": 1,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Quality Inspection Template",
"link_to": "Quality Inspection Template",
"link_type": "DocType",
"open_in_new_tab": 0,
"show_arrow": 0,
"type": "Link"
}
],
"standard": 1,
"title": "Quality",
"type": "Workspace"
}

Some files were not shown because too many files have changed in this diff Show More