mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-03 05:32:20 +00:00
Compare commits
133 Commits
chore/budg
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344f58b98a | ||
|
|
c9145c5ece | ||
|
|
2d0c0a8c09 | ||
|
|
e60a467972 | ||
|
|
e3e3d97a72 | ||
|
|
42c6768b4c | ||
|
|
2e13691ffa | ||
|
|
ae80d29dcf | ||
|
|
a33f7ead24 | ||
|
|
817ecaa92f | ||
|
|
8fd98ccbe2 | ||
|
|
bc82197bd1 | ||
|
|
9c53a91b82 | ||
|
|
21a9f2754e | ||
|
|
f0434cadd4 | ||
|
|
0ba43a17c1 | ||
|
|
4feaacc649 | ||
|
|
7034dc71e7 | ||
|
|
ed72732bb2 | ||
|
|
b12725c4b2 | ||
|
|
20928bd600 | ||
|
|
15862566a8 | ||
|
|
e446f54f2e | ||
|
|
cf9f16a921 | ||
|
|
6cffa0faeb | ||
|
|
a075437db7 | ||
|
|
9f4914e08f | ||
|
|
c9d712fa49 | ||
|
|
3345336a5c | ||
|
|
ceadc4f269 | ||
|
|
caa4358057 | ||
|
|
36f56fa1c3 | ||
|
|
5b738b7b0d | ||
|
|
f85f6be3cf | ||
|
|
6591ae195d | ||
|
|
7229957107 | ||
|
|
50b6f50b88 | ||
|
|
0888405640 | ||
|
|
087fb29d51 | ||
|
|
2bab709ac4 | ||
|
|
5298438905 | ||
|
|
e0b0926dff | ||
|
|
a3d22f4a51 | ||
|
|
3f9b8fe37e | ||
|
|
820f5498e7 | ||
|
|
71f02d412a | ||
|
|
f9ac05f4a1 | ||
|
|
c7fed29569 | ||
|
|
5514c64b7c | ||
|
|
2a8d26c0a7 | ||
|
|
56e7690e64 | ||
|
|
6e57bd325f | ||
|
|
8d70385019 | ||
|
|
f95baa54de | ||
|
|
3092c920ff | ||
|
|
55646667be | ||
|
|
9865f63613 | ||
|
|
08876ae07a | ||
|
|
489a799bc4 | ||
|
|
0790d2e6df | ||
|
|
04b94ed61f | ||
|
|
334b8ab09a | ||
|
|
7592f568ae | ||
|
|
bd21f506a1 | ||
|
|
2eec826219 | ||
|
|
4716084a41 | ||
|
|
81e838c4f8 | ||
|
|
c8eebd3a96 | ||
|
|
b6bdf81ce8 | ||
|
|
64db8072d8 | ||
|
|
cabdb7417d | ||
|
|
b4d3a879d2 | ||
|
|
040b33070b | ||
|
|
dae3a21b61 | ||
|
|
0769484fd6 | ||
|
|
683ef19b8a | ||
|
|
0e8ae7548d | ||
|
|
7f05b8ce58 | ||
|
|
e92a9c706b | ||
|
|
18d1947154 | ||
|
|
a69590b609 | ||
|
|
5adbc7baba | ||
|
|
7e7fd610cb | ||
|
|
ece8c9538d | ||
|
|
b77f6168d9 | ||
|
|
14f862f80c | ||
|
|
f1e91b6be6 | ||
|
|
835a050cfb | ||
|
|
2d3a1f5fab | ||
|
|
14091a8996 | ||
|
|
cc9d94efe8 | ||
|
|
c17517d22a | ||
|
|
4c9520bb1f | ||
|
|
7248053c6a | ||
|
|
c98ca6d2cc | ||
|
|
0a05dd4426 | ||
|
|
99fbd61bd9 | ||
|
|
b9e321c106 | ||
|
|
27f5235e67 | ||
|
|
cb4f3588fa | ||
|
|
7835f11f96 | ||
|
|
2e72c13aee | ||
|
|
898a70d340 | ||
|
|
435998cc4e | ||
|
|
ca7c6ca6da | ||
|
|
d10504af03 | ||
|
|
63cf379dbf | ||
|
|
65e3394481 | ||
|
|
8928b42d5d | ||
|
|
c47a95a4d2 | ||
|
|
f9029f8644 | ||
|
|
8b3c3d9fef | ||
|
|
2333afcd1e | ||
|
|
0f812e0686 | ||
|
|
48aef307f9 | ||
|
|
145a0b154e | ||
|
|
b09889643f | ||
|
|
4e88157ed7 | ||
|
|
8b7780d494 | ||
|
|
c38363c16d | ||
|
|
75ba81c79a | ||
|
|
376a5a2aee | ||
|
|
47ee1d126d | ||
|
|
e0bf3713ea | ||
|
|
eadaf37606 | ||
|
|
baae9bfb22 | ||
|
|
4f3dcd9e39 | ||
|
|
0e8b152c68 | ||
|
|
e9aac23913 | ||
|
|
2c3285286c | ||
|
|
8e560f1d1c | ||
|
|
2a1461c754 | ||
|
|
cccfdc72c9 |
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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})))
|
||||
@@ -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)
|
||||
@@ -21,7 +21,12 @@ def execute(filters=None):
|
||||
entries = get_entries(filters)
|
||||
invoice_details = get_invoice_posting_date_map(filters)
|
||||
|
||||
report = ReceivablePayableReport(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:
|
||||
|
||||
@@ -32,15 +32,15 @@ class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)
|
||||
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):
|
||||
# Row shape (positional): payment_document, payment_entry(voucher_no),
|
||||
# party_type, party, posting_date, invoice(against_voucher_no),
|
||||
# invoice_posting_date, due_date, amount, remarks, age,
|
||||
# range1, range2, range3, range4, [delay_in_payment]
|
||||
for row in data:
|
||||
if row[1] == payment_name:
|
||||
if row["payment_entry"] == payment_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
@@ -57,42 +57,60 @@ class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
|
||||
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()
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
# Positional assertions on the row shape.
|
||||
self.assertEqual(row[2], "Customer")
|
||||
self.assertEqual(row[4], getdate("2026-06-20")) # payment posting date
|
||||
self.assertEqual(row[5], invoice.name) # against invoice
|
||||
self.assertEqual(row[6], getdate("2026-06-01")) # invoice posting date
|
||||
self.assertEqual(row[8], 1000) # amount
|
||||
self.assertEqual(row[10], 19) # age = payment date - invoice date
|
||||
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[11], 1000) # range1 (0-30)
|
||||
self.assertEqual(row[12], 0) # range2 (30-60)
|
||||
self.assertEqual(row[13], 0) # range3 (60-90)
|
||||
self.assertEqual(row[14], 0) # range4 (90 Above)
|
||||
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()
|
||||
_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[8], 1000) # amount
|
||||
self.assertEqual(row[10], 45) # age = payment date - invoice date
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 45)
|
||||
# Buckets: 30-60 filled, others empty.
|
||||
self.assertEqual(row[11], 0) # range1 (0-30)
|
||||
self.assertEqual(row[12], 1000) # range2 (30-60)
|
||||
self.assertEqual(row[13], 0) # range3 (60-90)
|
||||
self.assertEqual(row[14], 0) # range4 (90 Above)
|
||||
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()
|
||||
|
||||
@@ -46,8 +46,9 @@ class TestProfitabilityAnalysis(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
def test_income_expense_and_gross_profit(self):
|
||||
# bootstrap leaf cost center; clean of committed GL so exact assertions hold
|
||||
cc = "_Test Cost Center - _TC"
|
||||
# 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)
|
||||
|
||||
@@ -74,7 +75,7 @@ class TestProfitabilityAnalysis(ERPNextTestSuite):
|
||||
self.assertEqual(parent_row["gross_profit_loss"], 7000)
|
||||
|
||||
def test_date_range_excludes_out_of_period_entries(self):
|
||||
cc = "_Test Cost Center 2 - _TC"
|
||||
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)
|
||||
@@ -97,7 +98,8 @@ class TestProfitabilityAnalysis(ERPNextTestSuite):
|
||||
data = self.run_report()
|
||||
# the report appends a blank separator row and a totals row at the end
|
||||
total_row = data[-1]
|
||||
self.assertEqual(total_row["account"], "'Total'")
|
||||
# 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
|
||||
|
||||
@@ -22,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"), filters.get("date"))
|
||||
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:
|
||||
@@ -61,16 +61,35 @@ def get_columns(filters):
|
||||
return columns
|
||||
|
||||
|
||||
def get_all_shares(shareholder, date):
|
||||
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."""
|
||||
transfers = frappe.get_all(
|
||||
"Share Transfer",
|
||||
filters={"docstatus": 1, "date": ("<=", date)},
|
||||
fields=["share_type", "no_of_shares", "rate", "amount", "from_shareholder", "to_shareholder"],
|
||||
order_by="date",
|
||||
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:
|
||||
|
||||
@@ -42,6 +42,30 @@ class TestShareBalanceReport(ERPNextTestSuite):
|
||||
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",
|
||||
|
||||
171
erpnext/accounts/report/share_ledger/test_share_ledger.py
Normal file
171
erpnext/accounts/report/share_ledger/test_share_ledger.py
Normal 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
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
)
|
||||
@@ -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, [])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -498,11 +498,7 @@ class BOM(WebsiteGenerator):
|
||||
order_by="sequence_id, idx",
|
||||
):
|
||||
child = self.append("operations", row)
|
||||
# guard against a 0/unset conversion rate (e.g. a foreign-currency BOM with no
|
||||
# exchange-rate record), mirroring the fallback used elsewhere in this file
|
||||
child.hour_rate = flt(
|
||||
row.hour_rate / (flt(self.conversion_rate) or 1), child.precision("hour_rate")
|
||||
)
|
||||
child.hour_rate = flt(row.hour_rate / self.conversion_rate, child.precision("hour_rate"))
|
||||
|
||||
@staticmethod
|
||||
def _get_routing_fields():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,37 +85,6 @@ class TestRouting(ERPNextTestSuite):
|
||||
self.assertEqual(bom_doc.operations[0].time_in_mins, 30)
|
||||
self.assertEqual(bom_doc.operations[1].time_in_mins, 20)
|
||||
|
||||
def test_get_routing_survives_zero_conversion_rate(self):
|
||||
# A foreign-currency BOM with no exchange-rate record leaves conversion_rate at 0.
|
||||
# get_routing() divides the operation hour rate by it and used to raise
|
||||
# ZeroDivisionError; it must now fall back to a rate of 1.
|
||||
operations = [
|
||||
{
|
||||
"operation": "Test Operation A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"hour_rate_rent": 300,
|
||||
"hour_rate_labour": 750,
|
||||
"time_in_mins": 30,
|
||||
},
|
||||
]
|
||||
setup_operations(operations)
|
||||
routing_doc = create_routing(
|
||||
routing_name="Zero Rate Route",
|
||||
operations=[
|
||||
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 30}
|
||||
],
|
||||
)
|
||||
|
||||
bom = frappe.new_doc("BOM")
|
||||
bom.routing = routing_doc.name
|
||||
bom.conversion_rate = 0
|
||||
|
||||
bom.get_routing() # must not raise ZeroDivisionError
|
||||
|
||||
self.assertTrue(bom.operations)
|
||||
# with the 0 rate falling back to 1, the hour rate is carried over unchanged
|
||||
self.assertEqual(bom.operations[0].hour_rate, routing_doc.operations[0].hour_rate)
|
||||
|
||||
|
||||
def setup_operations(rows):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
|
||||
@@ -132,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")
|
||||
@@ -141,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -9,77 +9,93 @@ 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()
|
||||
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)
|
||||
self.bom = build_bom_with_operation(self.fg_item, self.rm_item, OPERATION, WORKSTATION)
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"bom_id": [self.bom.name]})
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
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()
|
||||
rows = self.run_report(bom_id=[self.bom.name])
|
||||
|
||||
bom_rows = [row for row in rows if row.name == self.bom.name]
|
||||
self.assertEqual(len(bom_rows), 1)
|
||||
|
||||
row = bom_rows[0]
|
||||
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_scopes_to_bom(self):
|
||||
rows = self.run_report(item_code=self.fg_item)
|
||||
def test_item_code_filter_includes_matching_and_excludes_other(self):
|
||||
other_bom = self.build_other_bom()
|
||||
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all(row.item == self.fg_item for row in rows))
|
||||
self.assertIn(self.bom.name, {row.name for row in rows})
|
||||
# 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)
|
||||
|
||||
def test_workstation_filter(self):
|
||||
matching = self.run_report(workstation=WORKSTATION)
|
||||
self.assertIn(self.bom.name, {row.name for row in matching})
|
||||
# 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)
|
||||
|
||||
other_workstation = ensure_other_workstation()
|
||||
non_matching = self.run_report(workstation=other_workstation)
|
||||
self.assertNotIn(self.bom.name, {row.name for row in non_matching})
|
||||
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, do_not_submit=True
|
||||
make_item(properties={"is_stock_item": 1}).name,
|
||||
self.rm_item,
|
||||
OPERATION,
|
||||
WORKSTATION,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
rows = execute(frappe._dict({"bom_id": [draft_bom.name]}))[1]
|
||||
rows = self.run_report(bom_id=[draft_bom.name])
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
|
||||
def ensure_workstation_and_operation():
|
||||
if not frappe.db.exists("Workstation", WORKSTATION):
|
||||
frappe.get_doc({"doctype": "Workstation", "workstation_name": WORKSTATION}).insert(
|
||||
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(
|
||||
if not frappe.db.exists("Operation", operation):
|
||||
frappe.get_doc({"doctype": "Operation", "name": operation, "workstation": workstation}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
|
||||
def ensure_other_workstation():
|
||||
name = "_Test BOM Ops Time Workstation 2"
|
||||
if not frappe.db.exists("Workstation", name):
|
||||
frappe.get_doc({"doctype": "Workstation", "workstation_name": name}).insert(ignore_permissions=True)
|
||||
return name
|
||||
|
||||
|
||||
def build_bom_with_operation(fg_item, rm_item, do_not_submit=False):
|
||||
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],
|
||||
@@ -89,8 +105,8 @@ def build_bom_with_operation(fg_item, rm_item, do_not_submit=False):
|
||||
bom.append(
|
||||
"operations",
|
||||
{
|
||||
"operation": OPERATION,
|
||||
"workstation": WORKSTATION,
|
||||
"operation": operation,
|
||||
"workstation": workstation,
|
||||
"time_in_mins": TIME_IN_MINS,
|
||||
"hour_rate": 100,
|
||||
},
|
||||
|
||||
@@ -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, [])
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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, [])
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class TestProcessLossReport(ERPNextTestSuite):
|
||||
self.assertEqual(wo_order.process_loss_qty, 1)
|
||||
self.assertEqual(wo_order.produced_qty, 4)
|
||||
|
||||
data = self.run_report(work_order=wo_order.name)
|
||||
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")
|
||||
@@ -93,26 +93,22 @@ class TestProcessLossReport(ERPNextTestSuite):
|
||||
self.assertEqual(wo_order.process_loss_qty, 0)
|
||||
self.assertEqual(wo_order.produced_qty, 5)
|
||||
|
||||
data = self.run_report(work_order=wo_order.name)
|
||||
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_and_work_order_filters_are_ineffective(self):
|
||||
"""BUG: the `item` and `work_order` filters in process_loss_report.get_data
|
||||
call `query.where(...)` without reassigning the result. frappe's query
|
||||
builder is immutable, so `.where()` returns a new query and these extra
|
||||
conditions are silently dropped. A non-matching item filter therefore fails
|
||||
to exclude the row. This test documents the current (buggy) behaviour; if the
|
||||
report is fixed to reassign the query, update the assertion below to
|
||||
`assertIsNone`.
|
||||
"""
|
||||
def test_item_filter_scopes_rows(self):
|
||||
wo_order = self.make_manufactured_work_order(planned_qty=5, produced_qty=4)
|
||||
|
||||
# A non-matching item filter should exclude the row, but currently does not.
|
||||
data = self.run_report(item="_Test FG Item 2")
|
||||
self.assertIsNotNone(
|
||||
self.find_row(data, wo_order.name),
|
||||
"Filter bug regressed/fixed: `item` filter now takes effect - update this test",
|
||||
)
|
||||
# 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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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), [])
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)})
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.report.customer_wise_item_price.customer_wise_item_price import execute
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
PRICE_LIST = "Standard Selling"
|
||||
|
||||
|
||||
class TestCustomerWiseItemPrice(ERPNextTestSuite):
|
||||
"""The report lists sales items with the selling rate from the customer's price
|
||||
list and the available stock (summed across warehouses)."""
|
||||
|
||||
def setUp(self):
|
||||
self.item = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name
|
||||
self.customer = self.create_customer()
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Price",
|
||||
"item_code": self.item,
|
||||
"price_list": PRICE_LIST,
|
||||
"selling": 1,
|
||||
"price_list_rate": 250,
|
||||
}
|
||||
).insert()
|
||||
make_stock_entry(item_code=self.item, to_warehouse="Stores - _TC", qty=10, rate=100)
|
||||
|
||||
def create_customer(self):
|
||||
name = "_Test CWIP Customer"
|
||||
if not frappe.db.exists("Customer", name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": name,
|
||||
"customer_group": "_Test Customer Group",
|
||||
"territory": "_Test Territory",
|
||||
"default_price_list": PRICE_LIST,
|
||||
}
|
||||
).insert()
|
||||
return name
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict({"customer": self.customer})
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_customer_filter_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({}))
|
||||
|
||||
def test_selling_rate_and_available_stock_for_item(self):
|
||||
rows = self.run_report(item=self.item)
|
||||
|
||||
row = next((r for r in rows if r["item_code"] == self.item), None)
|
||||
self.assertIsNotNone(row, "Sales item missing from report")
|
||||
self.assertEqual(row["item_name"], frappe.db.get_value("Item", self.item, "item_name"))
|
||||
self.assertEqual(row["selling_rate"], 250) # from the customer's price list
|
||||
self.assertEqual(row["available_stock"], 10) # stocked into Stores - _TC
|
||||
self.assertEqual(row["price_list"], PRICE_LIST)
|
||||
|
||||
def test_item_filter_scopes_to_single_item(self):
|
||||
other = make_item(properties={"is_stock_item": 1, "is_sales_item": 1}).name
|
||||
|
||||
item_codes = {r["item_code"] for r in self.run_report(item=self.item)}
|
||||
self.assertIn(self.item, item_codes)
|
||||
self.assertNotIn(other, item_codes)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.report.quotation_trends.quotation_trends import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
FISCAL_YEAR = "_Test Fiscal Year 2026"
|
||||
TXN_DATE = "2026-06-01"
|
||||
|
||||
|
||||
class TestQuotationTrends(ERPNextTestSuite):
|
||||
"""The trends report buckets submitted Quotation quantities/amounts by period
|
||||
(Yearly/Monthly) for the chosen `based_on` dimension (Item, Customer, ...)."""
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"fiscal_year": FISCAL_YEAR,
|
||||
"based_on": "Item",
|
||||
"period": "Yearly",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
result = execute(filters)
|
||||
columns, data = result[0], result[1]
|
||||
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):
|
||||
"""Value at column `col_label` for the row whose `key_label` column equals
|
||||
`key_value`, or 0 when that row doesn't 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)
|
||||
|
||||
make_quotation(item="_Test Item", qty=4, rate=200, transaction_date=TXN_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 quotation hits "Jun (Qty)"/"(Amt)".
|
||||
labels, before = self.run_report(period="Monthly")
|
||||
before_jun_qty = self._cell(before, "Item", "_Test Item", "Jun (Qty)", labels)
|
||||
before_jun_amt = self._cell(before, "Item", "_Test Item", "Jun (Amt)", labels)
|
||||
before_may_qty = self._cell(before, "Item", "_Test Item", "May (Qty)", labels)
|
||||
|
||||
make_quotation(item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE)
|
||||
|
||||
labels, after = self.run_report(period="Monthly")
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Qty)", labels) - before_jun_qty, 3)
|
||||
# the amount path is a separate SUM(base_net_amount) case, so assert it too
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "Jun (Amt)", labels) - before_jun_amt, 300)
|
||||
# nothing was quoted in May, so that bucket is unchanged
|
||||
self.assertEqual(self._cell(after, "Item", "_Test Item", "May (Qty)", labels) - before_may_qty, 0)
|
||||
|
||||
def test_based_on_customer_groups_amount_by_party(self):
|
||||
# based_on Customer keys rows on the "Party" column (the customer id)
|
||||
labels, before = self.run_report(based_on="Customer")
|
||||
amt_col = f"{FISCAL_YEAR} (Amt)"
|
||||
before_amt = self._cell(before, "Party", "_Test Customer", amt_col, labels)
|
||||
|
||||
make_quotation(
|
||||
party_name="_Test Customer", item="_Test Item", qty=2, rate=150, transaction_date=TXN_DATE
|
||||
)
|
||||
|
||||
labels, after = self.run_report(based_on="Customer")
|
||||
self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300)
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.selling.report.sales_person_commission_summary.sales_person_commission_summary import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSalesPersonCommissionSummary(ERPNextTestSuite):
|
||||
"""The report joins a sales document (Sales Invoice/Order/Delivery Note) with its
|
||||
Sales Team rows, listing each sales person's contribution and commission."""
|
||||
|
||||
def setUp(self):
|
||||
# reuse the bootstrap sales persons (under the "Sales Team" group)
|
||||
self.sales_person = "_Test Sales Person"
|
||||
|
||||
def make_invoice_with_commission(self, percentage=100, commission_rate=5, incentives=50):
|
||||
si = create_sales_invoice(rate=1000, qty=1, do_not_save=True, posting_date="2026-06-01")
|
||||
si.append(
|
||||
"sales_team",
|
||||
{
|
||||
"sales_person": self.sales_person,
|
||||
"allocated_percentage": percentage,
|
||||
"commission_rate": commission_rate,
|
||||
"incentives": incentives,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
si.submit()
|
||||
si.reload() # reflect any values recomputed on submit
|
||||
return si
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"doc_type": "Sales Invoice",
|
||||
"sales_person": self.sales_person,
|
||||
# scope to this test's posting date so the query isn't unbounded over
|
||||
# every invoice for the shared sales person
|
||||
"from_date": "2026-06-01",
|
||||
"to_date": "2026-06-01",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_doc_type_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
|
||||
|
||||
def test_commission_row_matches_sales_team_entry(self):
|
||||
si = self.make_invoice_with_commission(percentage=100, commission_rate=5, incentives=50)
|
||||
team = si.sales_team[0]
|
||||
|
||||
rows = self.run_report()
|
||||
row = next((r for r in rows if r[0] == si.name), None)
|
||||
self.assertIsNotNone(row, "Invoice with commission missing from report")
|
||||
|
||||
# row: name, customer, territory, posting_date, base_net_amount, sales_person,
|
||||
# allocated_percentage, commission_rate, allocated_amount, incentives
|
||||
self.assertEqual(row[1], si.customer)
|
||||
self.assertEqual(row[4], si.base_net_total)
|
||||
self.assertEqual(row[5], self.sales_person)
|
||||
self.assertEqual(row[6], team.allocated_percentage)
|
||||
self.assertEqual(row[7], team.commission_rate)
|
||||
self.assertEqual(row[8], team.allocated_amount)
|
||||
self.assertEqual(row[9], team.incentives)
|
||||
|
||||
def test_appends_total_row(self):
|
||||
self.make_invoice_with_commission()
|
||||
rows = self.run_report()
|
||||
# the report appends a blank total row after one or more real data rows
|
||||
self.assertGreaterEqual(len(rows), 2)
|
||||
self.assertTrue(any(r[0] for r in rows[:-1]), "expected real data rows before the total row")
|
||||
self.assertEqual(rows[-1], [""] * len(rows[0]))
|
||||
|
||||
def test_sales_person_filter_scopes_rows(self):
|
||||
si = self.make_invoice_with_commission()
|
||||
|
||||
filtered = self.run_report(sales_person="_Test Sales Person 1")
|
||||
self.assertNotIn(si.name, {r[0] for r in filtered if r[0]})
|
||||
@@ -183,8 +183,22 @@ def get_entries(filters):
|
||||
.as_("contribution_amt")
|
||||
)
|
||||
|
||||
# Only pass valid document-field filters to get_query; report-specific keys such as
|
||||
# doc_type / sales_person / item_group are handled separately below.
|
||||
doc_filters = {"docstatus": 1}
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
doc_filters[field] = filters.get(field)
|
||||
|
||||
if filters.get("from_date") and filters.get("to_date"):
|
||||
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
|
||||
elif filters.get("from_date"):
|
||||
doc_filters[date_field] = [">=", filters.get("from_date")]
|
||||
elif filters.get("to_date"):
|
||||
doc_filters[date_field] = ["<=", filters.get("to_date")]
|
||||
|
||||
query = (
|
||||
frappe.get_query(dt, filters=filters, ignore_permissions=False)
|
||||
frappe.get_query(dt, filters=doc_filters, ignore_permissions=False)
|
||||
.join(dt_item)
|
||||
.on(dt.name == dt_item.parent)
|
||||
.join(st)
|
||||
@@ -203,48 +217,29 @@ def get_entries(filters):
|
||||
contribution_amt_case,
|
||||
)
|
||||
.where(st.parenttype == doc_type)
|
||||
.where(dt.docstatus == 1)
|
||||
)
|
||||
|
||||
if filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
query = query.where(
|
||||
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
|
||||
)
|
||||
|
||||
# only resolve items when an item_group/brand filter is set; otherwise get_items
|
||||
# would return every item in the system and add a huge IN() clause on each run
|
||||
if filters.get("item_group") or filters.get("brand"):
|
||||
items = get_items(filters)
|
||||
if not items:
|
||||
# the item_group/brand filter matched nothing -> no rows
|
||||
return []
|
||||
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
|
||||
|
||||
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
conditions = [""]
|
||||
values = []
|
||||
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
conditions.append(f"dt.{field}=%s")
|
||||
values.append(filters[field])
|
||||
|
||||
if filters.get("sales_person"):
|
||||
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
|
||||
conditions.append(
|
||||
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
|
||||
)
|
||||
|
||||
if filters.get("from_date"):
|
||||
conditions.append(f"dt.{date_field}>=%s")
|
||||
values.append(filters["from_date"])
|
||||
|
||||
if filters.get("to_date"):
|
||||
conditions.append(f"dt.{date_field}<=%s")
|
||||
values.append(filters["to_date"])
|
||||
|
||||
items = get_items(filters)
|
||||
if items:
|
||||
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
|
||||
values += items
|
||||
else:
|
||||
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
|
||||
conditions.append("dt_item.item_code = Null")
|
||||
|
||||
return " and ".join(conditions), values
|
||||
|
||||
|
||||
def get_items(filters):
|
||||
item = qb.DocType("Item")
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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.selling.report.sales_person_wise_transaction_summary.sales_person_wise_transaction_summary import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSalesPersonWiseTransactionSummary(ERPNextTestSuite):
|
||||
"""Item-level summary joining a sales document with its Sales Team rows, showing
|
||||
each sales person's contributed qty and amount per item line."""
|
||||
|
||||
def setUp(self):
|
||||
self.sales_person = "_Test Sales Person"
|
||||
|
||||
def make_invoice_with_commission(self, qty=5, rate=200, percentage=100):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item", qty=qty, rate=rate, do_not_save=True, posting_date="2026-06-01"
|
||||
)
|
||||
si.append("sales_team", {"sales_person": self.sales_person, "allocated_percentage": percentage})
|
||||
si.insert()
|
||||
si.submit()
|
||||
return si
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{"company": "_Test Company", "doc_type": "Sales Invoice", "sales_person": self.sales_person}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_doc_type_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
|
||||
|
||||
def test_invalid_doc_type_throws(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
frappe._dict({"company": "_Test Company", "doc_type": "Purchase Invoice"}),
|
||||
)
|
||||
|
||||
def test_item_line_contribution(self):
|
||||
si = self.make_invoice_with_commission(qty=5, rate=200, percentage=100)
|
||||
item = si.items[0]
|
||||
|
||||
rows = self.run_report()
|
||||
row = next((r for r in rows if r[0] == si.name and r[5] == "_Test Item"), None)
|
||||
self.assertIsNotNone(row, "Invoice item line missing from report")
|
||||
|
||||
# row: name, customer, territory, warehouse, posting_date, item_code, item_group,
|
||||
# brand, stock_qty, base_net_amount, sales_person, allocated_percentage,
|
||||
# contributed_qty, contribution_amt, currency
|
||||
self.assertEqual(row[1], si.customer)
|
||||
self.assertEqual(row[8], item.stock_qty)
|
||||
self.assertEqual(row[9], item.base_net_amount)
|
||||
self.assertEqual(row[10], self.sales_person)
|
||||
self.assertEqual(row[11], 100)
|
||||
self.assertEqual(row[12], item.stock_qty * 100 / 100) # contributed qty
|
||||
self.assertEqual(row[13], item.base_net_amount * 100 / 100) # contribution amount
|
||||
|
||||
def test_appends_total_row(self):
|
||||
self.make_invoice_with_commission()
|
||||
rows = self.run_report()
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(rows[-1], [""] * len(rows[0]))
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, nowdate
|
||||
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_person_target_variance_based_on_item_group.test_sales_person_target_variance_based_on_item_group import (
|
||||
create_target_distribution,
|
||||
)
|
||||
from erpnext.selling.report.territory_target_variance_based_on_item_group.territory_target_variance_based_on_item_group import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestTerritoryTargetVarianceBasedOnItemGroup(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.fiscal_year = get_fiscal_year(nowdate())[0]
|
||||
|
||||
def test_achieved_target_and_variance(self):
|
||||
distribution = create_target_distribution(self.fiscal_year)
|
||||
territory = create_territory_with_target(
|
||||
"_Test Target Territory", self.fiscal_year, distribution.name, target_qty=50
|
||||
)
|
||||
|
||||
# a Sales Order in that territory contributes to the achieved quantity
|
||||
so = make_sales_order(rate=1000, qty=20, do_not_submit=True)
|
||||
so.territory = territory.name
|
||||
so.submit()
|
||||
|
||||
result = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"fiscal_year": self.fiscal_year,
|
||||
"doctype": "Sales Order",
|
||||
"period": "Yearly",
|
||||
"target_on": "Quantity",
|
||||
}
|
||||
)
|
||||
)[1]
|
||||
|
||||
# no item_group is set on the target, so the report emits exactly one row per
|
||||
# territory -- assert all three figures against that single row
|
||||
rows = [frappe._dict(r) for r in result if r.get("territory") == territory.name]
|
||||
self.assertEqual(len(rows), 1, "expected exactly one row for the target territory")
|
||||
row = rows[0]
|
||||
self.assertEqual(flt(row.total_target, 2), 50)
|
||||
self.assertEqual(flt(row.total_achieved, 2), 20)
|
||||
self.assertEqual(flt(row.total_variance, 2), -30)
|
||||
|
||||
|
||||
def create_territory_with_target(name, fiscal_year, distribution_id, target_qty=50):
|
||||
doc = frappe.new_doc("Territory")
|
||||
doc.territory_name = name
|
||||
doc.parent_territory = "All Territories"
|
||||
doc.is_group = 0
|
||||
doc.append(
|
||||
"targets",
|
||||
{
|
||||
"fiscal_year": fiscal_year,
|
||||
"target_qty": target_qty,
|
||||
"target_amount": 30000,
|
||||
"distribution_id": distribution_id,
|
||||
},
|
||||
)
|
||||
return doc.insert()
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
|
||||
from erpnext.selling.report.territory_wise_sales.territory_wise_sales import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
TERRITORY = "_Test Territory"
|
||||
|
||||
|
||||
class TestTerritoryWiseSales(ERPNextTestSuite):
|
||||
"""The report walks the Opportunity -> Quotation -> Sales Order -> Sales Invoice
|
||||
funnel and totals each stage's amount per territory.
|
||||
|
||||
These tests cover the Opportunity and Quotation stages; the Sales Order and
|
||||
Sales Invoice (order_amount / billing_amount) stages are not yet exercised."""
|
||||
|
||||
def make_opportunity(self, amount=5000):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Opportunity",
|
||||
"opportunity_from": "Customer",
|
||||
"party_name": "_Test Customer",
|
||||
"territory": TERRITORY,
|
||||
"company": "_Test Company",
|
||||
"currency": "INR",
|
||||
"opportunity_amount": amount,
|
||||
"transaction_date": "2026-06-01",
|
||||
}
|
||||
).insert()
|
||||
|
||||
def make_quotation_for(self, opportunity, qty, rate):
|
||||
qo = make_quotation(item="_Test Item", qty=qty, rate=rate, do_not_save=True)
|
||||
qo.opportunity = opportunity.name
|
||||
qo.insert()
|
||||
qo.submit()
|
||||
return qo
|
||||
|
||||
def amount_for(self, territory, field):
|
||||
for row in execute(frappe._dict({"company": "_Test Company"}))[1]:
|
||||
if row["territory"] == territory:
|
||||
return row[field]
|
||||
return 0
|
||||
|
||||
def test_opportunity_amount_grouped_by_territory(self):
|
||||
before = self.amount_for(TERRITORY, "opportunity_amount")
|
||||
opp = self.make_opportunity(5000)
|
||||
self.assertEqual(opp.territory, TERRITORY)
|
||||
|
||||
after = self.amount_for(TERRITORY, "opportunity_amount")
|
||||
self.assertEqual(after - before, 5000)
|
||||
|
||||
def test_quotation_amount_flows_from_opportunity(self):
|
||||
before = self.amount_for(TERRITORY, "quotation_amount")
|
||||
|
||||
opp = self.make_opportunity()
|
||||
quotation = self.make_quotation_for(opp, qty=2, rate=500)
|
||||
|
||||
after = self.amount_for(TERRITORY, "quotation_amount")
|
||||
self.assertEqual(after - before, quotation.base_grand_total)
|
||||
@@ -339,7 +339,7 @@ erpnext.company.setup_queries = function (frm) {
|
||||
],
|
||||
[
|
||||
"stock_delivered_but_not_billed",
|
||||
{ root_type: "Liability", account_type: "Stock Delivered But Not Billed" },
|
||||
{ root_type: "Asset", account_type: "Stock Delivered But Not Billed" },
|
||||
],
|
||||
[
|
||||
"service_received_but_not_billed",
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"default_purchase_price_variance_account",
|
||||
"default_manufacturing_variance_account",
|
||||
"stock_received_but_not_billed",
|
||||
"enable_stock_delivered_but_not_billed",
|
||||
"stock_delivered_but_not_billed",
|
||||
"disable_sdbnb_in_sr",
|
||||
"default_provisional_account",
|
||||
@@ -1048,18 +1049,28 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "enable_stock_delivered_but_not_billed",
|
||||
"fieldname": "disable_sdbnb_in_sr",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable Stock Delivered But Not Billed in Sales Return",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "enable_stock_delivered_but_not_billed",
|
||||
"fieldname": "stock_delivered_but_not_billed",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Stock Delivered But Not Billed",
|
||||
"mandatory_depends_on": "enable_stock_delivered_but_not_billed",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, the value of goods delivered before invoicing will be recorded in the Stock Delivered But Not Billed account.",
|
||||
"fieldname": "enable_stock_delivered_but_not_billed",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Stock Delivered But Not Billed"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
|
||||
@@ -100,6 +100,7 @@ class Company(NestedSet):
|
||||
enable_item_wise_inventory_account: DF.Check
|
||||
enable_perpetual_inventory: DF.Check
|
||||
enable_provisional_accounting_for_non_stock_items: DF.Check
|
||||
enable_stock_delivered_but_not_billed: DF.Check
|
||||
exception_budget_approver_role: DF.Link | None
|
||||
exchange_gain_loss_account: DF.Link | None
|
||||
existing_company: DF.Link | None
|
||||
@@ -186,6 +187,64 @@ class Company(NestedSet):
|
||||
self.validate_inventory_account_settings()
|
||||
self.cant_change_valuation_method()
|
||||
self.validate_pending_reposts(old_doc)
|
||||
self.validate_sdbnb_configuration()
|
||||
|
||||
def validate_outstanding_sdbnb_transactions(self, account):
|
||||
GLEntry = frappe.qb.DocType("GL Entry")
|
||||
DeliveryNote = frappe.qb.DocType("Delivery Note")
|
||||
|
||||
delivery_notes = (
|
||||
frappe.qb.from_(GLEntry)
|
||||
.join(DeliveryNote)
|
||||
.on((GLEntry.voucher_type == "Delivery Note") & (GLEntry.voucher_no == DeliveryNote.name))
|
||||
.select(DeliveryNote.name)
|
||||
.where(
|
||||
(GLEntry.is_cancelled == 0)
|
||||
& (GLEntry.company == self.name)
|
||||
& (GLEntry.account == account)
|
||||
& (DeliveryNote.per_billed < 100)
|
||||
& (DeliveryNote.docstatus == 1)
|
||||
& (DeliveryNote.status.isin(["To Bill", "Partially Billed"]))
|
||||
)
|
||||
.distinct()
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
if delivery_notes:
|
||||
dn_links = ", ".join(get_link_to_form("Delivery Note", dn) for dn in delivery_notes[:10])
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Stock Delivered But Not Billed Account cannot be changed or disabled since account {0} contains outstanding Delivery Notes: {1}"
|
||||
).format(
|
||||
bold(account),
|
||||
dn_links,
|
||||
)
|
||||
)
|
||||
|
||||
def validate_sdbnb_configuration(self):
|
||||
if self.get("__islocal"):
|
||||
return
|
||||
|
||||
if self.enable_stock_delivered_but_not_billed and not self.stock_delivered_but_not_billed:
|
||||
frappe.throw(_("Please select Stock Delivered But Not Billed Account"))
|
||||
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
if not (doc_before_save and doc_before_save.stock_delivered_but_not_billed):
|
||||
return
|
||||
|
||||
account_changed = (
|
||||
self.stock_delivered_but_not_billed != doc_before_save.stock_delivered_but_not_billed
|
||||
)
|
||||
|
||||
feature_disabled = (
|
||||
doc_before_save.enable_stock_delivered_but_not_billed
|
||||
and not self.enable_stock_delivered_but_not_billed
|
||||
)
|
||||
|
||||
if account_changed or feature_disabled:
|
||||
self.validate_outstanding_sdbnb_transactions(doc_before_save.stock_delivered_but_not_billed)
|
||||
|
||||
def cant_change_valuation_method(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
@@ -10,7 +10,11 @@ from frappe.utils import random_string
|
||||
from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import (
|
||||
get_charts_for_country,
|
||||
)
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.setup.doctype.company.company import get_default_company_address
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -234,6 +238,44 @@ class TestCompany(ERPNextTestSuite):
|
||||
after = get_all_transactions_annual_history(company).get(key, 0)
|
||||
self.assertEqual(after - before, 2)
|
||||
|
||||
def test_sdbnb_validation_requires_account_when_enabled(self):
|
||||
company = get_test_company()
|
||||
|
||||
company.enable_stock_delivered_but_not_billed = 1
|
||||
company.stock_delivered_but_not_billed = None
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
company.save()
|
||||
|
||||
def test_disable_sdbnb_with_outstanding_delivery_note_fails(self):
|
||||
company = get_test_company()
|
||||
|
||||
item_code = create_stock_item_with_inventory()
|
||||
create_outstanding_delivery_note(item_code)
|
||||
|
||||
company.enable_stock_delivered_but_not_billed = 0
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
company.save()
|
||||
|
||||
def test_cannot_change_sdbnb_account_with_outstanding_delivery_note(self):
|
||||
company = get_test_company()
|
||||
|
||||
item_code = create_stock_item_with_inventory()
|
||||
create_outstanding_delivery_note(item_code)
|
||||
|
||||
new_account = create_account(
|
||||
account_name="Stock Delivered But Not Billed - New",
|
||||
account_type="Stock Delivered But Not Billed",
|
||||
parent_account="Stock Assets - _TSDBNB",
|
||||
company=company.name,
|
||||
)
|
||||
|
||||
company.stock_delivered_but_not_billed = new_account
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
company.save()
|
||||
|
||||
def test_demo_data(self):
|
||||
from erpnext.setup.demo import clear_demo_data, setup_demo_data
|
||||
|
||||
@@ -297,3 +339,49 @@ def create_test_lead_in_company(company):
|
||||
lead.company = company
|
||||
lead.save()
|
||||
return lead.name
|
||||
|
||||
|
||||
def get_test_company():
|
||||
if frappe.db.exists("Company", "_Test SDBNB Company"):
|
||||
return frappe.get_doc("Company", "_Test SDBNB Company")
|
||||
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": "_Test SDBNB Company",
|
||||
"abbr": "_TSDBNB",
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"enable_perpetual_inventory": 1,
|
||||
"enable_stock_delivered_but_not_billed": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
def create_stock_item_with_inventory():
|
||||
item_code = make_item(
|
||||
"SDBNB Test Item",
|
||||
properties={"is_stock_item": 1},
|
||||
).name
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item_code,
|
||||
target="Stores - _TSDBNB",
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
company="_Test SDBNB Company",
|
||||
)
|
||||
|
||||
return item_code
|
||||
|
||||
|
||||
def create_outstanding_delivery_note(item_code):
|
||||
return create_delivery_note(
|
||||
item_code=item_code,
|
||||
qty=5,
|
||||
rate=150,
|
||||
company="_Test SDBNB Company",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
expense_account="Stock Delivered But Not Billed - _TSDBNB",
|
||||
)
|
||||
|
||||
@@ -223,5 +223,17 @@
|
||||
"doctype": "Company",
|
||||
"chart_of_accounts": "Standard",
|
||||
"create_chart_of_accounts_based_on": "Standard Template"
|
||||
},
|
||||
{
|
||||
"abbr": "_TSDBNB",
|
||||
"company_name": "_Test SDBNB Company",
|
||||
"country": "India",
|
||||
"default_currency": "INR",
|
||||
"doctype": "Company",
|
||||
"domain": "Manufacturing",
|
||||
"chart_of_accounts": "Standard",
|
||||
"default_holiday_list": "_Test Holiday List",
|
||||
"enable_perpetual_inventory": 1,
|
||||
"enable_stock_delivered_but_not_billed": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -426,6 +426,7 @@ class DeliveryNote(SellingController):
|
||||
"stock_delivered_but_not_billed",
|
||||
"disable_sdbnb_in_sr",
|
||||
"default_expense_account",
|
||||
"enable_stock_delivered_but_not_billed",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
@@ -433,7 +434,7 @@ class DeliveryNote(SellingController):
|
||||
sdbnb_account = company_values.stock_delivered_but_not_billed
|
||||
disable_sdbnb_in_sr = company_values.disable_sdbnb_in_sr
|
||||
default_expense_account = company_values.default_expense_account
|
||||
|
||||
is_enabled_sdbnb = company_values.enable_stock_delivered_but_not_billed
|
||||
for item in self.items:
|
||||
if item.get("against_sales_invoice"):
|
||||
if sdbnb_account and item.expense_account == sdbnb_account:
|
||||
@@ -447,14 +448,16 @@ class DeliveryNote(SellingController):
|
||||
# Only stock items
|
||||
if is_stock_item and not item.get("is_fixed_asset") and not item.get("is_subcontracted"):
|
||||
# Sales Return handling
|
||||
if self.is_return and disable_sdbnb_in_sr:
|
||||
if self.is_return and disable_sdbnb_in_sr and sdbnb_account and is_enabled_sdbnb:
|
||||
if default_expense_account and (
|
||||
not item.expense_account or item.expense_account == sdbnb_account
|
||||
):
|
||||
item.expense_account = default_expense_account
|
||||
|
||||
elif sdbnb_account:
|
||||
elif sdbnb_account and is_enabled_sdbnb:
|
||||
item.expense_account = sdbnb_account
|
||||
elif sdbnb_account and item.expense_account == sdbnb_account:
|
||||
item.expense_account = default_expense_account
|
||||
if not item.expense_account and default_expense_account:
|
||||
item.expense_account = default_expense_account
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
self.load_test_records("Stock Entry")
|
||||
|
||||
def get_perpetual_defaults(self):
|
||||
company = frappe.get_doc("Company", "_Test Company with perpetual inventory")
|
||||
company = frappe.get_doc("Company", "_Test SDBNB Company")
|
||||
self.perpetual_company = company.name
|
||||
self.perpetual_account = company.stock_delivered_but_not_billed
|
||||
self.perpetual_cost_center = company.cost_center
|
||||
|
||||
@@ -239,6 +239,7 @@ class Item(Document):
|
||||
self.validate_item_defaults()
|
||||
self.validate_auto_reorder_enabled_in_stock_settings()
|
||||
self.cant_change()
|
||||
self.validate_serialized_change_with_bundle()
|
||||
self.validate_standard_cost_change()
|
||||
self.validate_item_tax_net_rate_range()
|
||||
|
||||
@@ -1130,6 +1131,25 @@ class Item(Document):
|
||||
|
||||
frappe.throw(msg, title=_("Linked with submitted documents"))
|
||||
|
||||
def validate_serialized_change_with_bundle(self):
|
||||
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
|
||||
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
|
||||
if self.is_new() or self.has_serial_no or not self._doc_before_save:
|
||||
return
|
||||
|
||||
# Only relevant when the item was serialized before and is now being unset.
|
||||
if not self._doc_before_save.has_serial_no:
|
||||
return
|
||||
|
||||
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
|
||||
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
|
||||
).format(frappe.bold(self.name)),
|
||||
title=_("Serial and Batch Bundle Exists"),
|
||||
)
|
||||
|
||||
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
|
||||
linked_doctypes = [
|
||||
"Delivery Note Item",
|
||||
|
||||
@@ -1120,6 +1120,47 @@ class TestItem(ERPNextTestSuite):
|
||||
sabb_qty = frappe.db.get_value("Serial and Batch Bundle", serial_and_batch_bundle, "total_qty")
|
||||
self.assertEqual(abs(sabb_qty), properties["opening_stock"])
|
||||
|
||||
def test_cannot_unset_serialized_while_bundle_exists(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
make_serial_batch_bundle,
|
||||
)
|
||||
|
||||
item = make_item(
|
||||
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
|
||||
).name
|
||||
|
||||
serial_no = f"{item}-SN-01"
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
|
||||
).insert()
|
||||
|
||||
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
|
||||
bundle = make_serial_batch_bundle(
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"company": "_Test Company",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"voucher_type": "Stock Entry",
|
||||
"serial_nos": [serial_no],
|
||||
"type_of_transaction": "Inward",
|
||||
"do_not_submit": True,
|
||||
"ignore_sabb_validation": True,
|
||||
}
|
||||
)
|
||||
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.has_serial_no = 0
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
# Once the bundle is removed, the item can be made non-serialized.
|
||||
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.has_serial_no = 0
|
||||
doc.save()
|
||||
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
|
||||
|
||||
|
||||
def set_item_variant_settings(fields):
|
||||
doc = frappe.get_doc("Item Variant Settings")
|
||||
|
||||
@@ -63,6 +63,13 @@ class PurchaseReceiptStockReservation:
|
||||
return
|
||||
|
||||
production_plan_references = self.get_production_plan_references()
|
||||
if not production_plan_references:
|
||||
return
|
||||
|
||||
reservable_plans = self.get_reservable_production_plans(production_plan_references)
|
||||
if not reservable_plans:
|
||||
return
|
||||
|
||||
production_plan_items = []
|
||||
doc.reload()
|
||||
|
||||
@@ -70,6 +77,9 @@ class PurchaseReceiptStockReservation:
|
||||
for row in doc.items:
|
||||
if row.material_request_item and row.material_request_item in production_plan_references:
|
||||
_ref = production_plan_references[row.material_request_item]
|
||||
if _ref.production_plan not in reservable_plans:
|
||||
continue
|
||||
|
||||
docnames.append(_ref.production_plan)
|
||||
row.update(
|
||||
{
|
||||
@@ -95,6 +105,25 @@ class PurchaseReceiptStockReservation:
|
||||
docnames, from_doctype="Production Plan", to_doctype="Work Order"
|
||||
)
|
||||
|
||||
def get_reservable_production_plans(self, production_plan_references: frappe._dict) -> set:
|
||||
"""Production Plans that opted into stock reservation (``reserve_stock``).
|
||||
|
||||
A Production Plan only gets this flag set if "Auto Reserve Stock" was enabled in
|
||||
Stock Settings when it was created, or the user ticked "Reserve Stock" manually.
|
||||
Without this check, a Purchase Receipt would auto-reserve stock for every
|
||||
Production Plan whenever "Enable Stock Reservation" is on, ignoring both of those.
|
||||
"""
|
||||
plan_names = {ref.production_plan for ref in production_plan_references.values()}
|
||||
return {
|
||||
p.name
|
||||
for p in frappe.get_all(
|
||||
"Production Plan",
|
||||
filters={"name": ["in", list(plan_names)]},
|
||||
fields=["name", "reserve_stock"],
|
||||
)
|
||||
if p.reserve_stock
|
||||
}
|
||||
|
||||
def get_production_plan_references(self) -> frappe._dict:
|
||||
production_plan_references = frappe._dict()
|
||||
material_request_items = []
|
||||
|
||||
@@ -511,7 +511,7 @@ def repost_gl_entries(doc):
|
||||
transactions = directly_dependent_transactions + list(repost_affected_transaction)
|
||||
|
||||
# handle stock delivered but not billed ledger entries
|
||||
if frappe.get_cached_value("Company", doc.company, "stock_delivered_but_not_billed"):
|
||||
if frappe.get_cached_value("Company", doc.company, "enable_stock_delivered_but_not_billed"):
|
||||
_update_post_delivery_billed_vouchers(transactions)
|
||||
|
||||
enable_separate_reposting_for_gl = frappe.db.get_single_value(
|
||||
|
||||
@@ -455,6 +455,7 @@ def get_basic_details(ctx: frappe._dict, item, overwrite_warehouse=True) -> frap
|
||||
[
|
||||
"stock_delivered_but_not_billed",
|
||||
"disable_sdbnb_in_sr",
|
||||
"enable_stock_delivered_but_not_billed",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
@@ -464,6 +465,7 @@ def get_basic_details(ctx: frappe._dict, item, overwrite_warehouse=True) -> frap
|
||||
and ctx.is_stock_item
|
||||
and company_values
|
||||
and company_values.stock_delivered_but_not_billed
|
||||
and company_values.enable_stock_delivered_but_not_billed
|
||||
and not ctx.get("is_fixed_asset")
|
||||
and not ctx.get("is_subcontracted")
|
||||
):
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,15 +4,18 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.cogs_by_item_group.cogs_by_item_group import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company with perpetual inventory"
|
||||
|
||||
|
||||
class TestCogsByItemGroup(ERPNextTestSuite):
|
||||
def run_report(self, **extra) -> list:
|
||||
filters = frappe._dict(
|
||||
company="_Test Company with perpetual inventory",
|
||||
company=COMPANY,
|
||||
from_date="2026-01-01",
|
||||
to_date="2026-12-31",
|
||||
)
|
||||
@@ -20,16 +23,21 @@ class TestCogsByItemGroup(ERPNextTestSuite):
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_cogs_for_item_group(self):
|
||||
# Reuse the bootstrap item `_Test Item` (item group `_Test Item Group`).
|
||||
# It has zero stock in `Stores - TCP1`, so this receipt starts from a clean balance.
|
||||
item = "_Test Item"
|
||||
# A dedicated item group with a single item keeps `agg_value` scoped to this
|
||||
# test's COGS. The report sums COGS up the whole item-group tree keyed on the
|
||||
# company's default expense account, so a shared group would accumulate COGS
|
||||
# booked by any other test/fixture for the same company within the date range.
|
||||
# The group name is unique per run so items created by earlier runs (which
|
||||
# reuse a fixed group name) can't inflate the total either.
|
||||
item_group = make_item_group(f"_Test COGS Item Group {frappe.generate_hash(length=6)}")
|
||||
item = make_item(properties={"is_stock_item": 1, "item_group": item_group}).name
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse="Stores - TCP1",
|
||||
qty=10,
|
||||
rate=100,
|
||||
company="_Test Company with perpetual inventory",
|
||||
company=COMPANY,
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
|
||||
@@ -40,7 +48,7 @@ class TestCogsByItemGroup(ERPNextTestSuite):
|
||||
qty=4,
|
||||
rate=150,
|
||||
warehouse="Stores - TCP1",
|
||||
company="_Test Company with perpetual inventory",
|
||||
company=COMPANY,
|
||||
update_stock=1,
|
||||
cost_center="Main - TCP1",
|
||||
parent_cost_center="Main - TCP1",
|
||||
@@ -51,7 +59,20 @@ class TestCogsByItemGroup(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
data = self.run_report()
|
||||
rows = [row for row in data if "_Test Item Group" in row.get("item_group")]
|
||||
self.assertTrue(rows, "No row found for _Test Item Group")
|
||||
rows = [row for row in data if item_group in row.get("item_group")]
|
||||
self.assertTrue(rows, "No row found for the dedicated item group")
|
||||
# 4 units delivered at 100 valuation rate -> 400 COGS.
|
||||
self.assertEqual(rows[0].get("cogs_debit"), 400)
|
||||
|
||||
|
||||
def make_item_group(name: str) -> str:
|
||||
if not frappe.db.exists("Item Group", name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": name,
|
||||
"parent_item_group": "All Item Groups",
|
||||
"is_group": 0,
|
||||
}
|
||||
).insert()
|
||||
return name
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.incorrect_balance_qty_after_transaction.incorrect_balance_qty_after_transaction import (
|
||||
execute,
|
||||
@@ -28,6 +29,30 @@ class TestIncorrectBalanceQtyAfterTransaction(ERPNextTestSuite):
|
||||
flagged = [row for row in data if row.get("item_code") == item]
|
||||
self.assertEqual(flagged, [])
|
||||
|
||||
def test_inconsistent_balance_qty_is_flagged(self):
|
||||
# a unique item keeps this SLE the only ledger entry for the item/warehouse
|
||||
item = make_item(properties={"is_stock_item": 1}).name
|
||||
entry = make_stock_entry(
|
||||
item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01"
|
||||
)
|
||||
|
||||
# Corrupt the running balance so it no longer matches the cumulative actual_qty --
|
||||
# exactly the inconsistency this report exists to detect. set_value bypasses the
|
||||
# ledger recompute that would otherwise keep the two in sync.
|
||||
sle_name = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": entry.name, "item_code": item, "warehouse": WAREHOUSE},
|
||||
"name",
|
||||
)
|
||||
frappe.db.set_value("Stock Ledger Entry", sle_name, "qty_after_transaction", 5)
|
||||
|
||||
flagged = [row for row in self.run_report(item_code=item) if row.get("name") == sle_name]
|
||||
self.assertEqual(len(flagged), 1, "The tampered stock ledger entry should be flagged")
|
||||
row = flagged[0]
|
||||
self.assertEqual(row["expected_balance_qty"], 10) # cumulative actual_qty
|
||||
self.assertEqual(row["qty_after_transaction"], 5) # tampered balance
|
||||
self.assertEqual(row["differnce"], 5)
|
||||
|
||||
def test_sequence_of_movements_not_flagged(self):
|
||||
item = "_Test Item 2"
|
||||
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=20, rate=50, posting_date="2026-06-01")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.item_wise_consumption.item_wise_consumption import execute
|
||||
@@ -22,7 +23,9 @@ class TestItemWiseConsumption(ERPNextTestSuite):
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_consumed_vs_delivered_split(self):
|
||||
item = "_Test Item"
|
||||
# a uniquely-named item guarantees no residual stock/consumption from other
|
||||
# tests leaks in -- the report aggregates an item across all warehouses.
|
||||
item = make_item(properties={"is_stock_item": 1}).name
|
||||
# purchase receipt gives the supplier mapping and stocks the item
|
||||
make_purchase_receipt(
|
||||
item_code=item,
|
||||
@@ -41,5 +44,7 @@ class TestItemWiseConsumption(ERPNextTestSuite):
|
||||
self.assertEqual(row[5], 400) # consumed amount
|
||||
self.assertEqual(row[6], 3) # delivered qty
|
||||
self.assertEqual(row[7], 300) # delivered amount
|
||||
self.assertEqual(row[8], 7) # total qty
|
||||
self.assertEqual(row[8], 7) # total qty = consumed + delivered
|
||||
self.assertEqual(row[9], 700) # total amount = consumed + delivered amount
|
||||
self.assertEqual(row[9], row[5] + row[7]) # total aggregates the two amounts
|
||||
self.assertIn("_Test Supplier", row[10])
|
||||
|
||||
@@ -3,28 +3,22 @@
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.report.negative_batch_report.negative_batch_report import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
WAREHOUSE = "Stores - _TC"
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestNegativeBatchReport(ERPNextTestSuite):
|
||||
def run_report(self, item_code):
|
||||
from erpnext.stock.report.negative_batch_report.negative_batch_report import execute
|
||||
filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE, "item_code": item_code})
|
||||
return execute(filters)[1]
|
||||
|
||||
return execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"warehouse": "Stores - _TC",
|
||||
"item_code": item_code,
|
||||
}
|
||||
)
|
||||
)[1]
|
||||
|
||||
def test_healthy_batch_not_negative(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item = make_item(
|
||||
def make_batch_item(self):
|
||||
return make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
@@ -33,22 +27,38 @@ class TestNegativeBatchReport(ERPNextTestSuite):
|
||||
}
|
||||
).name
|
||||
|
||||
def receive_batch(self, item, qty, posting_date):
|
||||
"""Receive `qty` of `item`, creating its batch, and return the batch no."""
|
||||
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=qty, rate=100, posting_date=posting_date)
|
||||
return frappe.get_all("Batch", filters={"item": item}, pluck="name")[0]
|
||||
|
||||
def test_healthy_batch_not_negative(self):
|
||||
item = self.make_batch_item()
|
||||
batch = self.receive_batch(item, 10, "2026-06-01")
|
||||
# issue from the same batch, staying within its balance
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse="Stores - _TC",
|
||||
qty=10,
|
||||
rate=100,
|
||||
posting_date="2026-06-01",
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
from_warehouse="Stores - _TC",
|
||||
qty=4,
|
||||
posting_date="2026-06-02",
|
||||
item_code=item, from_warehouse=WAREHOUSE, qty=4, batch_no=batch, posting_date="2026-06-02"
|
||||
)
|
||||
|
||||
# received 10 then issued 4 -> running batch balance never goes negative
|
||||
data = self.run_report(item)
|
||||
self.assertFalse([row for row in data if row.get("batch_no") == batch])
|
||||
|
||||
def test_negative_batch_is_flagged(self):
|
||||
# ERPNext blocks a negative batch balance at submission time (across several
|
||||
# layers), so a genuinely negative batch only exists as corrupt historical
|
||||
# data -- which is exactly what this report is meant to surface. Reproduce
|
||||
# that state directly by forcing the batch's ledger quantity below zero.
|
||||
item = self.make_batch_item()
|
||||
batch = self.receive_batch(item, 10, "2026-06-10")
|
||||
|
||||
sle = frappe.get_all("Stock Ledger Entry", filters={"item_code": item}, pluck="name")[0]
|
||||
entry = frappe.get_all("Serial and Batch Entry", filters={"batch_no": batch}, pluck="name")[0]
|
||||
frappe.db.set_value("Serial and Batch Entry", entry, "qty", -3)
|
||||
frappe.db.set_value("Stock Ledger Entry", sle, {"actual_qty": -3, "qty_after_transaction": -3})
|
||||
|
||||
data = self.run_report(item)
|
||||
|
||||
# The batch was only received (10) before being issued (4), so its running
|
||||
# balance never goes negative; the report must not list this item's batch.
|
||||
self.assertFalse([row for row in data if row.get("item_code") == item])
|
||||
flagged = [row for row in data if row.get("batch_no") == batch]
|
||||
self.assertEqual(len(flagged), 1, "A batch with a negative running balance must be flagged")
|
||||
self.assertEqual(flagged[0]["qty_after_transaction"], -3)
|
||||
self.assertEqual(flagged[0]["warehouse"], WAREHOUSE)
|
||||
|
||||
@@ -505,7 +505,7 @@ class FIFOSlots:
|
||||
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
|
||||
elif batch_nos and row.get("has_batch_no"):
|
||||
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
|
||||
elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
|
||||
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
|
||||
self._add_to_negative_fifo_head(row, fifo_queue)
|
||||
else:
|
||||
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])
|
||||
|
||||
@@ -1434,6 +1434,47 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
self.assertEqual(item_result["total_qty"], -4.0)
|
||||
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
|
||||
|
||||
def test_untagged_receipt_with_negative_batch_head(self):
|
||||
"""An incoming SLE without batch details must not treat a negative
|
||||
batch slot at the queue head as a qty slot (TypeError: str += float)."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Enclosure Item",
|
||||
actual_qty=-10,
|
||||
qty_after_transaction=-10,
|
||||
stock_value_difference=-100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no="QI-06448",
|
||||
),
|
||||
frappe._dict(
|
||||
name="Enclosure Item",
|
||||
actual_qty=45,
|
||||
qty_after_transaction=35,
|
||||
stock_value_difference=1051.65,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-05",
|
||||
voucher_type="Purchase Receipt",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
batch_no=None,
|
||||
serial_and_batch_bundle="SABB-00001294",
|
||||
),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots["Enclosure Item"]["fifo_queue"]
|
||||
|
||||
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
|
||||
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
|
||||
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
|
||||
|
||||
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
|
||||
from frappe.utils import add_days, getdate, nowdate
|
||||
|
||||
|
||||
@@ -176,6 +176,10 @@ class SerialBatchBundleService:
|
||||
parent_details = self.get_parent_details_for_packed_items()
|
||||
|
||||
for row in self.doc.get(table_name):
|
||||
item_code = row.get("rm_item_code") or row.get("item_code")
|
||||
if not item_code or not self.is_serial_batch_item(item_code):
|
||||
continue
|
||||
|
||||
if (
|
||||
not via_landed_cost_voucher
|
||||
and row.serial_and_batch_bundle
|
||||
|
||||
@@ -275,10 +275,11 @@ def repost_gate(item_code, warehouse):
|
||||
racing into a lock-order deadlock. Row locks still enforce correctness; this only cuts the
|
||||
deadlock/retry churn. Scope is repost-vs-repost only -- the synchronous repost_current_voucher
|
||||
submit path is deliberately not gated (blocking a submit behind a background repost would be a
|
||||
worse regression) and keeps relying on the existing deadlock retry. No advisory locks, no gate."""
|
||||
worse regression) and keeps relying on the existing deadlock retry. Postgres only: MariaDB
|
||||
keeps the plain deadlock-retry path."""
|
||||
# hasattr keeps this a graceful opt-in: on an ERPNext predating frappe.db.advisory_lock, fall
|
||||
# back to no gate rather than raising and marking the Repost Item Valuation permanently Failed.
|
||||
if frappe.db.db_type in ("postgres", "mariadb") and hasattr(frappe.db, "advisory_lock"):
|
||||
if frappe.db.db_type == "postgres" and hasattr(frappe.db, "advisory_lock"):
|
||||
# Tuple key: a colon in item_code/warehouse can't collide two distinct pairs onto one lock.
|
||||
return frappe.db.advisory_lock(("stock_repost", item_code, warehouse), timeout=REPOST_LOCK_TIMEOUT)
|
||||
return nullcontext()
|
||||
|
||||
Reference in New Issue
Block a user