Compare commits

..

28 Commits

Author SHA1 Message Date
Nabin Hait
b85776c00b Merge pull request #56843 from frappe/chore/test-share-transfer
test: cover Share Transfer consistency validations
2026-07-03 23:44:40 +05:30
Nabin Hait
0f7ee3a843 Merge pull request #56844 from frappe/chore/test-process-statement-of-accounts
test: cover Process Statement Of Accounts validation
2026-07-03 23:44:29 +05:30
Nabin Hait
e546132ac3 Merge pull request #56845 from frappe/chore/test-chart-of-accounts-importer
test: add coverage for Chart of Accounts Importer parsing
2026-07-03 23:44:20 +05:30
Nabin Hait
24a13d16bb Merge pull request #56847 from frappe/chore/test-asset-capitalization
test: cover Asset Capitalization row validations
2026-07-03 23:43:52 +05:30
Nabin Hait
17e7f91690 Merge pull request #56850 from frappe/chore/test-packing-slip
test: cover Packing Slip package-number and item validations
2026-07-03 23:43:43 +05:30
Nabin Hait
ef7fb1084d Merge pull request #56854 from frappe/chore/test-serial-batch-bundle-dark-paths
test: cover Serial and Batch Bundle helpers and validations
2026-07-03 23:43:04 +05:30
Nabin Hait
18d16fa5cf Merge pull request #56851 from frappe/chore/test-email-digest
test: cover Email Digest date-window calculations
2026-07-03 23:42:25 +05:30
Nikhil Kothari
dc09362454 fix: replace all old icons (#56864) 2026-07-03 23:03:28 +05:30
Nabin Hait
2000a9db36 Merge pull request #56842 from frappe/chore/test-bank-reconciliation-tool
test: cover Bank Reconciliation Tool date filter and message helper
2026-07-03 22:08:23 +05:30
Nabin Hait
dd35d977f7 Merge pull request #56829 from frappe/chore/test-process-subscription
test: add coverage for Process Subscription
2026-07-03 22:06:19 +05:30
Mihir Kandoi
4545dd939a Merge pull request #56837 from aerele/fix/manufacture-stock-entry-cost-center-default
fix: remove company default on cost center in stock entry detail
2026-07-03 21:11:52 +05:30
rohitwaghchaure
7b0c35caaf fix: auto fetch serial no from previous operation output (#56445)
* fix: auto fetch serial no from previous operation output

* fix: order by

* fix: warehouse for operations
2026-07-03 20:23:33 +05:30
rohitwaghchaure
341a07dffa fix: restrict state-changing whitelisted endpoints to POST (#56858)
Add methods=["POST"] to 50 whitelisted functions that create or modify
documents (get_doc followed by insert/save/submit), so they can no
longer be invoked via GET requests.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:47:13 +05:30
rohitwaghchaure
9c911438f1 fix: do not rebook standard cost variance on non-update-stock purchase invoice (#56799) 2026-07-03 17:17:38 +05:30
Nabin Hait
3240411876 test: drop unused timedelta import 2026-07-03 17:05:53 +05:30
Nabin Hait
8f96e5f2aa test: replace lambda with nested def (ruff E731) 2026-07-03 17:03:51 +05:30
Nabin Hait
8456e88d93 test: cover Serial and Batch Bundle helpers and in-memory validations 2026-07-03 16:20:41 +05:30
Nabin Hait
c5ab9958ff test: cover Email Digest date-window calculations 2026-07-03 16:01:10 +05:30
Nabin Hait
113d914b9c test: cover Packing Slip package-number and item validations 2026-07-03 15:59:13 +05:30
Nabin Hait
ebd8547629 test: cover Asset Capitalization row validations 2026-07-03 15:54:17 +05:30
Nabin Hait
740c5a07ff test: add coverage for Chart of Accounts Importer parsing 2026-07-03 14:55:31 +05:30
Nabin Hait
3dfb3f385b test: cover Process Statement Of Accounts validation defaults 2026-07-03 14:53:22 +05:30
Nabin Hait
dae90e90df test: cover Share Transfer consistency validations 2026-07-03 14:50:52 +05:30
Nabin Hait
d1d592cf0c test: cover bank reconciliation date filter and auto-reconcile message 2026-07-03 14:48:21 +05:30
Nabin Hait
0b20438da9 test: mirror subscription test setup for known settings 2026-07-03 14:24:58 +05:30
pandiyan
a168bb7ea4 test: cover cost center fallback to item group default in manufacture entry
the existing test_cost_center_for_manufacture only checks a raw material
row against an item-level override, which is set independently of the
":company" default guard and never exercised the bug.
2026-07-03 14:06:22 +05:30
pandiyan
edfa0a7a1d fix: remove company default on cost center in stock entry detail
the ":company" default pre-filled every row before set_default_cost_center()
ran, so its "if not row.cost_center" guard was always false and the
project/item group/brand priority chain in get_default_cost_center()
never ran.
2026-07-03 14:06:22 +05:30
Nabin Hait
745f657a0e test: add coverage for Process Subscription 2026-07-03 11:37:02 +05:30
148 changed files with 1916 additions and 576 deletions

View File

@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<p>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
</p>
</td></tr>

View File

@@ -188,7 +188,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
return {"balance": 0, "date": None}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
"""
Set the closing balance as per statement for a bank account and date

View File

@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def update_bank_transaction(
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
):
@@ -146,7 +146,7 @@ def update_bank_transaction(
)[0]
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_journal_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_payment_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
return output
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_internal_transfer(
bank_transaction_name: str | int,
posting_date: str | date,
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
return alert_message, indicator
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
vouchers = frappe.parse_json(vouchers)

View File

@@ -8,6 +8,7 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
@@ -97,3 +98,40 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.save()
.submit()
)
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")
self.assertIn("No matches", message)
def test_auto_reconcile_message_counts_and_pluralizes(self):
# reconciled count is reported and the indicator turns green
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
self.assertEqual(indicator, "green")
self.assertIn("2 Transaction(s) Reconciled", message)
# partially-reconciled label is singular for one, plural for many
singular, _ = get_auto_reconcile_message(["p1"], [])
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)

View File

@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
"""
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction

View File

@@ -34,7 +34,7 @@ def upload_bank_statement():
return {"columns": columns, "data": data}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_bank_entries(columns: str, data: str | list, bank_account: str):
header_map = get_header_mapping(columns, bank_account)

View File

@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
self.get_report_summary()
self.update_node()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def bisect_left(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Left"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def bisect_right(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Right"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def move_up(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)

View File

@@ -1,47 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import datetime
# import frappe
import frappe
from frappe.utils import getdate
from erpnext.tests.utils import ERPNextTestSuite
class TestBisectAccountingStatements(ERPNextTestSuite):
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
These cover the date validation and that the bisection cleanly partitions the range."""
def setUp(self):
frappe.set_user("Administrator")
frappe.db.delete("Bisect Nodes")
def _leaf_days(self):
leaves = frappe.get_all(
"Bisect Nodes",
filters={"left_child": ["is", "not set"]},
fields=["period_from_date", "period_to_date"],
)
# every leaf spans a single day
for leaf in leaves:
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
def test_validate_dates_rejects_reversed_range(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.from_date = "2026-01-08"
doc.to_date = "2026-01-01"
self.assertRaises(frappe.ValidationError, doc.validate)
def test_bfs_partitions_range_into_single_days(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
def test_dfs_produces_the_same_partition_as_bfs(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
pass

View File

@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
return from_year.year_start_date, to_year.year_end_date
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def revise_budget(budget_name: str):
old_budget = frappe.get_doc("Budget", budget_name)

View File

@@ -1,8 +1,54 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
build_forest,
validate_columns,
validate_missing_roots,
)
from erpnext.tests.utils import ERPNextTestSuite
# columns: account_name, parent_account, account_number, parent_account_number,
# is_group, account_type, root_type, account_currency
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
class TestChartofAccountsImporter(ERPNextTestSuite):
pass
"""The importer parses an uploaded CoA into a nested tree and validates its
shape. These cover the parsing/validation helpers without a file upload."""
def test_validate_columns_rejects_blank_file(self):
self.assertRaises(frappe.ValidationError, validate_columns, [])
def test_validate_columns_requires_eight_columns(self):
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
# the standard template width passes
validate_columns([ROOT])
def test_build_forest_nests_child_under_parent(self):
forest = build_forest([ROOT, CHILD])
self.assertIn("Assets", forest)
self.assertIn("Cash", forest["Assets"])
def test_build_forest_rejects_unknown_parent(self):
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
def test_build_forest_requires_account_name(self):
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
def test_validate_missing_roots_requires_all_root_types(self):
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
self.assertRaises(
frappe.ValidationError,
validate_missing_roots,
[{"root_type": rt} for rt in present],
)
# all five root types present -> no error
validate_missing_roots(
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
)

View File

@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
pass
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_or_update_cheque_print_format(template_name: str):
frappe.only_for("System Manager")

View File

@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}
</h4>
<ul>

View File

@@ -414,21 +414,17 @@ frappe.ui.form.on("Payment Entry", {
show_general_ledger: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
}
},

View File

@@ -718,7 +718,7 @@ class PaymentRequest(Document):
row_number += TO_SKIP_NEW_ROW
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_payment_request(**args):
"""Make payment request"""

View File

@@ -41,21 +41,17 @@ frappe.ui.form.on("Period Closing Voucher", {
refresh: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
}
},
});

View File

@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}
</h4>
<ul>
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
</ul>
</td></tr>
<tr><td>
<h4><i class="fa fa-question-sign"></i>
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
${__("How Pricing Rule is applied?")}
</h4>
<ol>

View File

@@ -113,3 +113,38 @@ def create_process_soa(**args):
process_soa.update(soa_dict)
process_soa.save()
return process_soa
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
"""validate() fills in default subject/body/pdf templates and enforces the
basic constraints. Exercised on the document directly (no email/PDF flow)."""
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
doc = frappe.new_doc("Process Statement Of Accounts")
doc.report = report
doc.company = "_Test Company"
if with_customer:
doc.append("customers", {"customer": "_Test Customer"})
doc.update(overrides)
return doc
def test_customers_are_required(self):
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
def test_general_ledger_body_uses_a_date_range(self):
doc = self.make_soa(report="General Ledger")
doc.validate()
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
# subject and pdf name are also defaulted
self.assertTrue(doc.subject)
self.assertTrue(doc.pdf_name)
def test_receivable_body_uses_the_posting_date(self):
doc = self.make_soa(report="Accounts Receivable")
doc.validate()
self.assertIn("until {{ doc.posting_date }}", doc.body)
def test_account_must_belong_to_company(self):
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other, "need an account in _Test Company 1")
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)

View File

@@ -1,11 +1,56 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from unittest.mock import patch
import frappe
from erpnext.accounts.doctype.process_subscription.process_subscription import (
create_subscription_process,
)
from erpnext.accounts.doctype.subscription.test_subscription import (
create_parties,
create_subscription,
make_plans,
reset_settings,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessSubscription(ERPNextTestSuite):
pass
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
for every non-cancelled Subscription (or just one when a subscription is named)."""
def setUp(self):
frappe.set_user("Administrator")
# mirror TestSubscription setup so subscriptions build against known settings
make_plans()
create_parties()
reset_settings()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
def enqueued_subscriptions(self, subscription=None):
"""Submit a Process Subscription while capturing what gets enqueued."""
calls = []
def capture(*args, **kwargs):
calls.append(kwargs)
with patch("frappe.enqueue", side_effect=capture):
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
# each enqueue is handed a batch (list) of subscription names
return [name for call in calls for name in call.get("subscription", [])]
def test_named_subscription_is_the_only_one_enqueued(self):
sub = create_subscription(start_date="2026-01-01")
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
def test_cancelled_subscriptions_are_skipped(self):
active = create_subscription(start_date="2026-01-01")
cancelled = create_subscription(start_date="2026-01-01")
cancelled.cancel_subscription()
enqueued = self.enqueued_subscriptions()
self.assertIn(active.name, enqueued)
self.assertNotIn(cancelled.name, enqueued)

View File

@@ -3,7 +3,6 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form
import erpnext
@@ -131,7 +130,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
)
from erpnext.stock.utils import get_valuation_method
doc = self.doc
tax_service = TaxService(doc)
@@ -331,33 +329,25 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
handled = False
if (
item.item_code
and item.item_code in stock_items
and item.get("purchase_receipt")
and not doc.is_return
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
):
handled = self.make_standard_cost_srbnb_split(
gl_entries, item, expense_account, account_currency, base_amount
)
if not handled:
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
# When Update Stock is disabled, this invoice has no stock impact: the linked
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
# amount against the supplier - booking PPV again would double count it and leave
# SRBNB partially uncleared.
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# check if the exchange rate has changed
if (
@@ -530,95 +520,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
},
)
def make_standard_cost_srbnb_split(
self, gl_entries, item, expense_account, account_currency, base_amount
):
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
value the receipt actually booked and post the (Net Amount - standard) difference to the
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
can't be resolved."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
doc = self.doc
precision = item.precision("base_net_amount")
standard_value = flt(self.get_pr_stock_value(item), precision)
if not standard_value:
return False
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": standard_value,
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
variance = flt(base_amount - standard_value, precision)
if variance:
gl_entries.append(
self.get_gl_dict(
{
"account": get_purchase_price_variance_account(item.item_code, doc.company),
"against": doc.supplier,
"debit": variance,
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
item=item,
)
)
return True
def get_pr_stock_value(self, item):
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
row is billing.
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
non-stock invoice).
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
pr_detail = frappe.db.get_value(
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
)
if not pr_detail or not pr_detail.warehouse:
return 0.0
sle = frappe.qb.DocType("Stock Ledger Entry")
result = (
frappe.qb.from_(sle)
.select(Sum(sle.stock_value_difference))
.where(
(sle.voucher_type == "Purchase Receipt")
& (sle.voucher_no == item.purchase_receipt)
& (sle.voucher_detail_no == item.pr_detail)
& (sle.warehouse == pr_detail.warehouse)
& (sle.is_cancelled == 0)
)
).run()
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
if not accepted_value or not flt(pr_detail.stock_qty):
return accepted_value
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
def get_stock_variance_account(self, item):
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
Variance; for all other items it keeps the existing behaviour (default expense account)."""

View File

@@ -121,3 +121,65 @@ class TestShareTransfer(ERPNextTestSuite):
}
)
self.assertRaises(ShareDontExists, doc.insert)
class TestShareTransferValidation(ERPNextTestSuite):
"""basic_validations() enforces the transfer's internal consistency. Exercised
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
are needed - it only reasons about the document's own fields."""
def make_transfer(self, **overrides):
doc = frappe.new_doc("Share Transfer")
doc.update(
{
"transfer_type": "Transfer",
"date": "2026-01-01",
"from_shareholder": "SH-A",
"to_shareholder": "SH-B",
"to_folio_no": "1",
"share_type": "Equity",
"from_no": 1,
"to_no": 100,
"no_of_shares": 100,
"rate": 10,
"amount": 1000,
"company": "_Test Company",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.update(overrides)
return doc
def test_baseline_transfer_is_consistent(self):
# the helper's defaults must pass, otherwise the negative cases prove nothing
self.make_transfer().basic_validations()
def test_seller_and_buyer_must_differ(self):
doc = self.make_transfer(to_shareholder="SH-A")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_share_count_must_match_the_number_range(self):
# 1..100 is 100 shares, not 50
doc = self.make_transfer(no_of_shares=50)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_must_equal_rate_times_shares(self):
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_is_derived_when_left_blank(self):
doc = self.make_transfer(amount=0)
doc.basic_validations()
self.assertEqual(doc.amount, 1000)
def test_equity_or_liability_account_is_required(self):
doc = self.make_transfer(equity_or_liability_account=None)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_issue_requires_a_to_shareholder(self):
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_purchase_requires_a_from_shareholder(self):
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)

View File

@@ -15,7 +15,7 @@
"label": "Banking",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.924019",
"modified": "2026-07-03 13:43:50.924019",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Banking",
@@ -43,7 +43,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "tool",
"icon": "wrench",
"indent": 0,
"keep_closed": 0,
"label": "Bank Reconciliation",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"icon": "wallet",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Budgeting",
"link_type": "DocType",
"links": [],
"modified": "2026-07-02 04:24:48.116724",
"modified": "2026-07-03 04:24:48.116724",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budgeting",
@@ -59,7 +59,7 @@
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "accounting",
"icon": "wallet",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",

View File

@@ -266,7 +266,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:08.095321",
"modified": "2026-07-03 13:44:08.095321",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -284,7 +284,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "accounting",
"icon": "wallet",
"indent": 1,
"keep_closed": 0,
"label": "Financial Reports",

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"icon": "wallet",
"idx": 4,
"indicator_color": "",
"is_hidden": 0,
@@ -587,7 +587,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:08.471142",
"modified": "2026-07-03 13:44:08.471142",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -622,7 +622,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -635,7 +635,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -786,7 +786,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "money-coins-1",
"icon": "coins",
"indent": 1,
"keep_closed": 0,
"label": "Payments",

View File

@@ -15,7 +15,7 @@
"label": "Payments",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.184761",
"modified": "2026-07-03 13:43:50.184761",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -31,7 +31,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -44,7 +44,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "money-coins-1",
"icon": "coins",
"indent": 1,
"keep_closed": 0,
"label": "Payments",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "money-coins-1",
"icon": "coins",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Share Management",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:51.040978",
"modified": "2026-07-03 13:43:51.040978",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Share Management",
@@ -30,7 +30,7 @@
{
"child": 1,
"collapsible": 1,
"icon": "customer",
"icon": "user",
"indent": 0,
"keep_closed": 0,
"label": "Shareholder",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "accounting",
"icon": "wallet",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Subscriptions",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 14:08:36.999272",
"modified": "2026-07-03 14:08:36.999272",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscriptions",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "money-coins-1",
"icon": "coins",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Taxes",
"link_type": "DocType",
"links": [],
"modified": "2026-06-14 13:43:50.894825",
"modified": "2026-07-03 13:43:50.894825",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -58,7 +58,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"icon": "package",
"indent": 0,
"keep_closed": 0,
"label": "Item Tax Template",

View File

@@ -587,3 +587,47 @@ def get_actual_sle_dict(name):
}
return sle_dict
class TestAssetCapitalizationValidation(ERPNextTestSuite):
"""Row-level validations for the consumed/target items. Exercised on the document
directly (the integration tests above cover the full capitalization posting)."""
def make_capitalization(self, **fields):
doc = frappe.new_doc("Asset Capitalization")
doc.company = "_Test Company"
doc.update(fields)
return doc
def test_source_items_are_mandatory(self):
doc = self.make_capitalization()
self.assertRaises(frappe.ValidationError, doc.validate_source_mandatory)
def test_target_item_must_be_a_fixed_asset(self):
# _Test Item is a stock item, not a fixed asset
doc = self.make_capitalization(target_item_code="_Test Item")
self.assertRaises(frappe.ValidationError, doc.validate_target_item)
def test_consumed_stock_row_rejects_a_non_stock_item(self):
doc = self.make_capitalization()
doc.append("stock_items", {"item_code": "_Test Non Stock Item", "stock_qty": 1})
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
def test_consumed_stock_row_requires_positive_qty(self):
doc = self.make_capitalization()
doc.append("stock_items", {"item_code": "_Test Item", "stock_qty": 0})
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
def test_service_row_rejects_a_stock_item(self):
doc = self.make_capitalization()
doc.append("service_items", {"item_code": "_Test Item", "qty": 1, "rate": 100})
self.assertRaises(frappe.ValidationError, doc.validate_service_item)
def test_service_row_requires_positive_qty_and_rate(self):
zero_qty = self.make_capitalization()
zero_qty.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 0, "rate": 100})
self.assertRaises(frappe.ValidationError, zero_qty.validate_service_item)
zero_rate = self.make_capitalization()
zero_rate.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 1, "rate": 0})
self.assertRaises(frappe.ValidationError, zero_rate.validate_service_item)

View File

@@ -224,7 +224,7 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_node():
from frappe.desk.treeview import make_tree_args

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "assets",
"icon": "archive",
"idx": 0,
"is_hidden": 0,
"label": "Assets",
@@ -199,7 +199,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:08.417956",
"modified": "2026-07-03 13:44:08.417956",
"modified_by": "Administrator",
"module": "Assets",
"module_onboarding": "Asset Onboarding",
@@ -217,7 +217,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -230,7 +230,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -295,7 +295,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",

View File

@@ -55,7 +55,7 @@ def make_supplier_quotation_from_rfq(
# This method is used to make supplier quotation from supplier's portal.
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_supplier_quotation(doc: str | Document | dict):
doc = frappe.parse_json(doc)

View File

@@ -185,7 +185,7 @@ def refresh_scorecards():
frappe.get_doc("Supplier Scorecard", sc_name).save()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_all_scorecards(docname: str):
sc = frappe.get_doc("Supplier Scorecard", docname)
supplier = frappe.get_doc("Supplier", sc.supplier)

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "buying",
"icon": "shopping-cart",
"idx": 0,
"is_hidden": 0,
"label": "Buying",
@@ -501,7 +501,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:43:50.509039",
"modified": "2026-07-03 13:43:50.509039",
"modified_by": "Administrator",
"module": "Buying",
"module_onboarding": "Buying Onboarding",
@@ -532,7 +532,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -545,7 +545,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -610,7 +610,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "liabilities",
"icon": "scale",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice",

View File

@@ -1724,7 +1724,7 @@ def get_missing_company_details(doctype: str, docname: str):
}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def update_company_master_and_address(current_doctype: str, name: str, company: str, details: dict | str):
from frappe.utils import validate_email_address

View File

@@ -653,7 +653,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
return [item for item in items if item.get("item_code") in inspection_required_items]
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_quality_inspections(
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
):

View File

@@ -17,7 +17,7 @@ frappe.ui.form.on("Campaign", {
frappe.route_options = { utm_source: "Campaign", utm_campaign: frm.doc.name };
frappe.set_route("List", "Lead");
},
"fa fa-list",
null,
true
);
}

View File

@@ -380,7 +380,7 @@ def get_lead_with_phone_number(number):
return lead
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_lead_to_prospect(lead: str, prospect: str):
prospect = frappe.get_doc("Prospect", prospect)
prospect.append("leads", {"lead": lead})

View File

@@ -110,7 +110,7 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
return target_doc
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""

View File

@@ -124,7 +124,7 @@ def make_supplier_quotation(source_name: str, target_doc: str | Document | None
return doclist
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_opportunity_from_communication(
communication: str, company: str, ignore_communication_links: bool = False
):

View File

@@ -389,7 +389,7 @@ def get_item_details(item_code: str):
}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_multiple_status(names: str | list[str], status: str):
names = frappe.parse_json(names)
for name in names:

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "crm",
"icon": "handshake",
"idx": 0,
"is_hidden": 0,
"label": "CRM",
@@ -421,7 +421,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:08.297053",
"modified": "2026-07-03 13:44:08.297053",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM",
@@ -471,7 +471,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -510,7 +510,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "customer",
"icon": "user",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
@@ -644,7 +644,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",
@@ -776,7 +776,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "sell",
"icon": "store",
"indent": 1,
"keep_closed": 1,
"label": "Campaign",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "accounting",
"icon": "wallet",
"icon_type": "Folder",
"idx": 1,
"label": "Accounting",
"link_to": "",
"link_type": "Workspace Sidebar",
"modified": "2026-01-27 17:04:04.351402",
"modified": "2026-07-03 17:04:04.351402",
"modified_by": "Administrator",
"name": "Accounting",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "assets",
"icon": "archive",
"icon_type": "Link",
"idx": 1,
"label": "Assets",
"link_to": "Assets",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.220411",
"modified": "2026-07-03 20:07:01.220411",
"modified_by": "Administrator",
"name": "Assets",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "expenses",
"icon": "chart-pie",
"icon_type": "Link",
"idx": 6,
"label": "Budget",
"link_to": "Budget",
"link_type": "Workspace Sidebar",
"modified": "2026-01-23 14:39:30.839274",
"modified": "2026-07-03 14:39:30.839274",
"modified_by": "Administrator",
"name": "Budget",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "buying",
"icon": "shopping-cart",
"icon_type": "Link",
"idx": 1,
"label": "Buying",
"link_to": "Buying",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.196163",
"modified": "2026-07-03 20:07:01.196163",
"modified_by": "Administrator",
"name": "Buying",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 1,
"icon": "crm",
"icon": "handshake",
"icon_type": "Link",
"idx": 1,
"label": "CRM",
"link_to": "CRM",
"link_type": "Workspace Sidebar",
"modified": "2026-01-06 14:54:05.112927",
"modified": "2026-07-03 14:54:05.112927",
"modified_by": "Administrator",
"name": "CRM",
"owner": "Administrator",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "setting",
"icon": "settings",
"icon_type": "Link",
"idx": 10,
"label": "ERPNext Settings",
"link_to": "ERPNext Settings",
"link_type": "Workspace Sidebar",
"logo_url": "",
"modified": "2026-01-09 14:59:56.044037",
"modified": "2026-07-03 14:59:56.044037",
"modified_by": "Administrator",
"name": "ERPNext Settings",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 1,
"icon": "home",
"icon": "house",
"icon_type": "Link",
"idx": 0,
"label": "Home",
"link_to": "Home",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.174950",
"modified": "2026-07-03 20:07:01.174950",
"modified_by": "Administrator",
"name": "Home",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "accounting",
"icon": "wallet",
"icon_type": "Link",
"idx": 0,
"label": "Invoicing",
"link_to": "Invoicing",
"link_type": "Workspace Sidebar",
"modified": "2026-01-23 15:17:23.564795",
"modified": "2026-07-03 15:17:23.564795",
"modified_by": "Administrator",
"name": "Invoicing",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "organization",
"icon": "factory",
"icon_type": "Link",
"idx": 1,
"label": "Manufacturing",
"link_to": "Manufacturing",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.246693",
"modified": "2026-07-03 20:07:01.246693",
"modified_by": "Administrator",
"name": "Manufacturing",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "project",
"icon": "folder-kanban",
"icon_type": "Link",
"idx": 1,
"label": "Projects",
"link_to": "Projects",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.226383",
"modified": "2026-07-03 20:07:01.226383",
"modified_by": "Administrator",
"name": "Projects",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "quality",
"icon": "shield-check",
"icon_type": "Link",
"idx": 1,
"label": "Quality",
"link_to": "Quality",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.239523",
"modified": "2026-07-03 20:07:01.239523",
"modified_by": "Administrator",
"name": "Quality",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "sell",
"icon": "store",
"icon_type": "Link",
"idx": 1,
"label": "Selling",
"link_to": "Selling",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.189446",
"modified": "2026-07-03 20:07:01.189446",
"modified_by": "Administrator",
"name": "Selling",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "stock",
"icon": "package",
"icon_type": "Link",
"idx": 1,
"label": "Stock",
"link_to": "Stock",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.212940",
"modified": "2026-07-03 20:07:01.212940",
"modified_by": "Administrator",
"name": "Stock",
"owner": "Administrator",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "getting-started",
"icon": "package-2",
"icon_type": "Link",
"idx": 6,
"label": "Subcontracting",
"link_to": "Subcontracting",
"link_type": "Workspace Sidebar",
"logo_url": "/assets/erpnext/desktop_icons/subcontracting.svg",
"modified": "2026-01-01 20:07:01.323508",
"modified": "2026-07-03 20:07:01.323508",
"modified_by": "Administrator",
"name": "Subcontracting",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 1,
"icon": "support",
"icon": "headset",
"icon_type": "Link",
"idx": 1,
"label": "Support",
"link_to": "Support",
"link_type": "Workspace Sidebar",
"modified": "2026-01-06 14:53:54.100467",
"modified": "2026-07-03 14:53:54.100467",
"modified_by": "Administrator",
"name": "Support",
"owner": "Administrator",

View File

@@ -50,7 +50,7 @@ def get_plaid_configuration():
return "disabled"
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_institution(token: str, response: str | dict):
response = frappe.parse_json(response)
@@ -79,7 +79,7 @@ def add_institution(token: str, response: str | dict):
return bank
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
response = frappe.parse_json(response)
bank = frappe.parse_json(bank)

View File

@@ -86,6 +86,10 @@ def make_material_request(source_name: str, target_doc: Document | str | None =
@frappe.whitelist()
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
def update_item(source, target, source_parent):
target.t_warehouse = source_parent.wip_warehouse
@@ -125,6 +129,7 @@ def make_stock_entry(source_name: str, target_doc: Document | str | None = None)
wo_allows_alternate_item
and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
)
set_previous_operation_serial_batch(target, item)
doclist = get_mapped_doc(
"Job Card",

View File

@@ -1061,6 +1061,9 @@ class TestJobCard(ERPNextTestSuite):
job_card.submit()
for row in fg_bom.items:
if row.item_code == sfg.name:
continue
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
@@ -1071,9 +1074,301 @@ class TestJobCard(ERPNextTestSuite):
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
sfg_row = next(row for row in manufacturing_entry.items if row.item_code == sfg.name)
self.assertEqual(flt(sfg_row.basic_rate, 3), 95.0)
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
"""Batch produced by an operation should auto-pull into the next operation's
semi-finished consumption row (skip-transfer Manufacture entry)."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("Auto Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("Auto Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("Auto Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"Auto Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "AP-SFG-.#####",
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "Auto Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "Auto Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
# Operation A -> produces the SFG batch
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-01-01 08:00:00",
"to_time": "2024-01-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
self.assertTrue(sfg_fg_row.serial_and_batch_bundle)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
# Operation B -> consumes the SFG; its batch should be auto-pulled from Operation A
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_b.for_quantity,
},
)
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
sfg_consume_row = next(r for r in me_b.items if r.item_code == sfg and r.s_warehouse)
self.assertTrue(
sfg_consume_row.serial_and_batch_bundle,
"Previous operation's batch was not auto-pulled into the semi-finished consumption row",
)
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_semi_fg_auto_pull_with_uom_conversion(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("UOM Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("UOM Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("UOM Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"UOM Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "UP-SFG-.#####",
"uoms": [{"uom": "Box", "conversion_factor": 5}],
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "UOM Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "UOM Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=5, basic_rate=100, posting_date="2024-01-01")
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "UOM Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
se = frappe.new_doc("Stock Entry")
se.company = "_Test Company"
se.purpose = "Material Transfer"
se.work_order = work_order.name
se.set_stock_entry_type()
row = se.append(
"items",
{
"item_code": sfg,
"qty": 1,
"uom": "Box",
"conversion_factor": 5,
"s_warehouse": warehouse,
"t_warehouse": "_Test Warehouse - _TC",
},
)
set_previous_operation_serial_batch(se, row)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
abs(frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")),
5.0,
)
se.save()
se.submit()
se.reload()
row = se.items[0]
consumed_batches = get_batches_from_bundle(row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
self.assertEqual(abs(sum(consumed_batches.values())), 5.0)
def test_secondary_items_without_sfg(self):
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:

View File

@@ -169,6 +169,29 @@ class OperationsService:
self.doc.set("operations", operations)
self.calculate_time()
self.set_operation_warehouses()
def set_operation_warehouses(self):
"""For semi-finished goods tracking, default each operation's warehouses from the Work
Order and chain them: the first operation pulls from the WO source warehouse and every
later operation pulls from the previous operation's output; intermediate outputs go to the
WIP warehouse while the final operation outputs to the WO finished goods warehouse.
Only empty fields are filled, so values configured on the BOM/operation are preserved."""
if not self.doc.track_semi_finished_goods or not self.doc.operations:
return
operations = self.doc.operations
last_idx = len(operations) - 1
for idx, op in enumerate(operations):
if not op.source_warehouse:
op.source_warehouse = self.doc.source_warehouse
if not op.fg_warehouse:
op.fg_warehouse = self.doc.fg_warehouse if idx == last_idx else self.doc.source_warehouse
if not op.wip_warehouse:
op.wip_warehouse = self.doc.wip_warehouse
def _collect_bom_operations(self):
operations = []

View File

@@ -691,6 +691,28 @@ class TestWorkOrder(ERPNextTestSuite):
ste.save()
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
# "_Test Item Group" is master data with buying_cost_center already set to
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
# BOM need to be created, since no existing item in that group has one.
fg_item = make_item(
"_Test FG Item For Item Group Cost Center",
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
)
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
wo_order = make_wo_order_test_record(
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
)
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
ste.insert()
fg_row = next(d for d in ste.items if d.is_finished_item)
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
def test_operation_time_with_batch_size(self):
fg_item = "Test Batch Size Item For BOM"
rm1 = "Test Batch Size Item RM 1 For BOM"

View File

@@ -288,6 +288,7 @@ class WorkOrder(Document):
self.validate_sales_order()
self.set_default_warehouse()
self.set_operation_warehouses()
self.validate_warehouse_belongs_to_company()
self.check_wip_warehouse_skip()
self.calculate_operating_cost()
@@ -975,6 +976,9 @@ class WorkOrder(Document):
def set_work_order_operations(self):
return OperationsService(self).set_work_order_operations()
def set_operation_warehouses(self):
return OperationsService(self).set_operation_warehouses()
def update_operation_status(self):
return OperationsService(self).update_operation_status()
@@ -1070,7 +1074,7 @@ def get_bom_operations(doctype: str, txt: str, searchfield: str, start: int, pag
return frappe.get_all("BOM Operation", filters=filters, fields=["operation"], as_list=1)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_work_order_ops(name: str):
po = frappe.get_doc("Work Order", name)
po.set_work_order_operations()

View File

@@ -232,8 +232,8 @@ class WorkstationDashboard {
.find(".section-body-job-card")
.hasClass("hide")
)
$(e.currentTarget).html(frappe.utils.icon("es-line-down", "sm", "mb-1"));
else $(e.currentTarget).html(frappe.utils.icon("es-line-up", "sm", "mb-1"));
$(e.currentTarget).html(frappe.utils.icon("chevron-down", "sm", "mb-1"));
else $(e.currentTarget).html(frappe.utils.icon("chevron-up", "sm", "mb-1"));
});
}

View File

@@ -223,7 +223,7 @@ class Workstation(Document):
return schedule_date
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("write")
@@ -233,7 +233,7 @@ class Workstation(Document):
return doc
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("submit")

View File

@@ -80,7 +80,7 @@
<span>
<span class="menu-btn-group-label">
<svg class="icon icon-sm">
<use href="#icon-dot-vertical">
<use href="#icon-ellipsis-vertical">
</use>
</svg>
</span>

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "organization",
"icon": "building-2",
"idx": 1,
"is_hidden": 0,
"label": "Manufacturing",
@@ -432,7 +432,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:07.420267",
"modified": "2026-07-03 13:44:07.420267",
"modified_by": "Administrator",
"module": "Manufacturing",
"module_onboarding": "Manufacturing Onboarding",
@@ -463,7 +463,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -476,7 +476,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -528,7 +528,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"icon": "package",
"indent": 0,
"keep_closed": 0,
"label": "Stock Entry",
@@ -541,7 +541,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
"label": "Material Planning",
@@ -627,7 +627,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "tool",
"icon": "wrench",
"indent": 1,
"keep_closed": 1,
"label": "Tools",

View File

@@ -628,7 +628,7 @@ def allow_to_make_project_update(project, time, frequency):
return True
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_duplicate_project(prev_doc: str | dict, project_name: str):
"""Create duplicate project based on the old project"""
import json
@@ -779,7 +779,7 @@ def create_kanban_board_if_not_exists(project: str):
return True
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_project_status(project: str, status: str):
"""
set status for project and all related tasks

View File

@@ -369,7 +369,7 @@ def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len:
)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_multiple_status(names: str | list, status: str):
names = frappe.parse_json(names)
for name in names:
@@ -451,7 +451,7 @@ def get_children(
return tasks
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_node():
from frappe.desk.treeview import make_tree_args
@@ -465,7 +465,7 @@ def add_node():
frappe.get_doc(args).insert()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_multiple_tasks(data: str | list, parent: str):
data = frappe.parse_json(data)
new_doc = {"doctype": "Task", "parent_task": parent if parent != "All Tasks" else ""}

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "project",
"icon": "folder-kanban",
"idx": 1,
"is_hidden": 0,
"label": "Projects",
@@ -367,7 +367,7 @@
"type": "Link"
}
],
"modified": "2026-07-01 13:20:50.651608",
"modified": "2026-07-03 13:20:50.651608",
"modified_by": "Administrator",
"module": "Projects",
"module_onboarding": "Projects Onboarding",
@@ -399,7 +399,7 @@
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -413,7 +413,7 @@
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -427,7 +427,7 @@
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "projects",
"icon": "folder-kanban",
"indent": 0,
"keep_closed": 0,
"label": "Project",

View File

@@ -111,7 +111,7 @@ class BOMConfigurator {
this.frm?.doc.docstatus === 0
? [
{
label: __(frappe.utils.icon("edit", "sm") + " BOM"),
label: __(frappe.utils.icon("pencil", "sm") + " BOM"),
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.edit_bom(node, view);
@@ -119,7 +119,7 @@ class BOMConfigurator {
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("add", "sm") + " Raw Material"),
label: __(frappe.utils.icon("plus", "sm") + " Raw Material"),
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.add_item(node, view);
@@ -130,7 +130,7 @@ class BOMConfigurator {
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("add", "sm") + " Sub Assembly"),
label: __(frappe.utils.icon("plus", "sm") + " Sub Assembly"),
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.add_sub_assembly(node, view);
@@ -141,7 +141,7 @@ class BOMConfigurator {
btnClass: "hidden-xs",
},
{
label: __(frappe.utils.icon("add", "sm") + " Phantom Item"),
label: __(frappe.utils.icon("plus", "sm") + " Phantom Item"),
click: function (node) {
let view = frappe.views.trees["BOM Configurator"];
view.events.add_sub_assembly(node, view, true);

View File

@@ -118,7 +118,6 @@ erpnext.setup.slides_settings = [
// Organization
name: "organization",
title: __("Setup your organization"),
icon: "fa fa-building",
fields: [
{
fieldname: "company_name",

View File

@@ -27,7 +27,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlDat
`
<span class="phone-btn">
<a class="btn-open no-decoration" title="${__("Make a call")}">
${frappe.utils.icon("call")}
${frappe.utils.icon("phone")}
</span>
`
)

View File

@@ -10,7 +10,7 @@
<span>
<a class="action-btn" href="/app/call-log/{{ name }}" title="{{ __("Open Call Log") }}">
<svg class="icon icon-sm">
<use href="#icon-link-url" class="like-icon"></use>
<use href="#icon-link" class="like-icon"></use>
</svg>
</a>
</span>

View File

@@ -3,7 +3,7 @@
<span>
<button class="btn btn-sm small new-task-btn mr-1">
<svg class="icon icon-sm">
<use href="#icon-small-message"></use>
<use href="#icon-message-circle"></use>
</svg>
{{ __("New Task") }}
</button>
@@ -27,7 +27,7 @@
<div class="row label-area font-md ml-1">
<span class="mr-2">
<svg class="icon icon-sm">
<use href="#icon-small-message"></use>
<use href="#icon-message-circle"></use>
</svg>
</span>
<a href="/app/todo/{{ tasks[i].name }}" title="{{ __('Open Task') }}">

View File

@@ -2,7 +2,7 @@
<div class="new-btn pb-3">
<button class="btn btn-sm small new-note-btn mr-1">
<svg class="icon icon-sm">
<use href="#icon-add"></use>
<use href="#icon-plus"></use>
</svg>
{{ __("New Note") }}
</button>
@@ -33,7 +33,7 @@
</div>
<div class="col-xs-1 text-right">
<span class="edit-note-btn btn btn-link">
<svg class="icon icon-sm"><use xlink:href="#icon-edit"></use></svg>
<svg class="icon icon-sm"><use xlink:href="#icon-pencil"></use></svg>
</span>
<span class="delete-note-btn btn btn-link pl-2">
<svg class="icon icon-xs"><use xlink:href="#icon-delete"></use></svg>

View File

@@ -491,7 +491,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
const clear_btn = `
<a class="btn-clear-last-scanned-warehouse" title="${__("Clear Last Scanned Warehouse")}">
${frappe.utils.icon("close", "xs", "es-icon")}
${frappe.utils.icon("x", "xs")}
</a>
`;

View File

@@ -148,7 +148,7 @@ def get_children(
)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_node():
from frappe.desk.treeview import make_tree_args

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "quality",
"icon": "shield-check",
"idx": 0,
"is_hidden": 0,
"label": "Quality",
@@ -161,7 +161,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:07.920643",
"modified": "2026-07-03 13:44:07.920643",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality",
@@ -178,7 +178,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -217,7 +217,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "review",
"icon": "star",
"indent": 0,
"keep_closed": 0,
"label": "Quality Review",

View File

@@ -196,7 +196,7 @@ class Customer(TransactionBase):
if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100:
frappe.throw(_("Total contribution percentage should be equal to 100"))
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def get_customer_group_details(self):
doc = frappe.get_doc("Customer Group", self.customer_group)
self.accounts = []

View File

@@ -74,7 +74,7 @@ erpnext.selling.InstallationNote = class InstallationNote extends frappe.ui.form
},
});
},
"fa fa-download",
null,
"btn-default"
);
}

View File

@@ -840,7 +840,7 @@ def set_delivery_date(items: list, sales_order: str) -> None:
item.schedule_date = delivery_by_bundle.get(item.product_bundle)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def make_work_orders(items: str | dict, sales_order: str, company: str, project: str | None = None):
"""Make Work Orders against the given Sales Order for the given `items`"""
items = frappe.parse_json(items).get("items")

View File

@@ -347,7 +347,7 @@ def check_opening_entry(user: str):
return open_vouchers
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_opening_voucher(pos_profile: str, company: str, balance_details: str | list):
balance_details = frappe.parse_json(balance_details)
@@ -438,7 +438,7 @@ def get_past_order_list(search_term: str, status: str, limit: int = 20):
return invoice_list
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def set_customer_info(fieldname: str, customer: str, value: str = ""):
customer_doc = frappe.get_doc("Customer", customer)
customer_doc.check_permission("write")

View File

@@ -279,14 +279,14 @@ erpnext.PointOfSale.ItemSelector = class {
this.search_field.$wrapper.find(".control-input").append(
`<span class="link-btn">
<a class="btn-open no-decoration" title="${__("Clear")}">
${frappe.utils.icon("close", "sm")}
${frappe.utils.icon("x", "sm")}
</a>
</span>`
);
this.item_group_field.$wrapper.find(".link-btn").append(
`<a class="btn-clear" tabindex="-1" style="display: inline-block;" title="${__("Clear Link")}">
${frappe.utils.icon("close", "xs", "es-icon")}
${frappe.utils.icon("x", "xs")}
</a>`
);

View File

@@ -57,7 +57,7 @@ erpnext.SalesFunnel = class SalesFunnel {
function () {
me.get_data();
},
"fa fa-refresh"
"refresh-cw"
),
});

View File

@@ -2,9 +2,9 @@
"creation": "2013-10-04 13:17:18.000000",
"docstatus": 0,
"doctype": "Page",
"icon": "fa fa-filter",
"icon": "funnel",
"idx": 1,
"modified": "2013-10-04 13:17:18.000000",
"modified": "2026-07-03 13:17:18.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "sales-funnel",

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "sell",
"icon": "store",
"idx": 0,
"is_hidden": 0,
"label": "Selling",
@@ -622,7 +622,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:44:07.820564",
"modified": "2026-07-03 13:44:07.820564",
"modified_by": "Administrator",
"module": "Selling",
"module_onboarding": "Selling Onboarding",
@@ -653,7 +653,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "home",
"icon": "house",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -666,7 +666,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart",
"icon": "chart-column",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -692,7 +692,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "sell",
"icon": "store",
"indent": 0,
"keep_closed": 0,
"label": "Sales Order",
@@ -839,7 +839,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"icon": "package",
"indent": 1,
"keep_closed": 1,
"label": "Items & Pricing",

View File

@@ -1007,7 +1007,7 @@ def get_children(doctype: str, parent: str | None = None, company: str | None =
)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_node():
from frappe.desk.treeview import make_tree_args
@@ -1118,7 +1118,7 @@ def get_billing_shipping_address(
return {"primary_address": primary_address, "shipping_address": shipping_address}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_transaction_deletion_request(company: str):
frappe.only_for("System Manager")

View File

@@ -95,7 +95,7 @@ def get_children(
return frappe.get_all("Department", fields=fields, filters=filters, order_by="name")
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def add_node():
from frappe.desk.treeview import make_tree_args

View File

@@ -2,7 +2,7 @@
# See license.txt
import frappe
from frappe.utils import add_days, today
from frappe.utils import add_days, getdate, now_datetime, today
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -116,3 +116,38 @@ def create_email_digest(**args):
doc.insert()
return doc
class TestEmailDigestDates(ERPNextTestSuite):
"""The digest's reporting windows are pure date math driven by the frequency."""
def make_digest(self, frequency, from_date="2026-06-15"):
doc = frappe.new_doc("Email Digest")
doc.frequency = frequency
doc.from_date = getdate(from_date)
doc.to_date = getdate(from_date)
return doc
def test_set_dates_daily_looks_back_one_day(self):
doc = self.make_digest("Daily")
doc.set_dates()
self.assertEqual(doc.past_from_date, getdate("2026-06-14"))
self.assertEqual(doc.past_to_date, getdate("2026-06-14"))
def test_set_dates_weekly_looks_back_one_week(self):
doc = self.make_digest("Weekly")
doc.set_dates()
self.assertEqual(doc.past_from_date, getdate("2026-06-08"))
self.assertEqual(doc.past_to_date, getdate("2026-06-14"))
def test_set_dates_monthly_looks_back_one_month(self):
doc = self.make_digest("Monthly")
doc.set_dates()
self.assertEqual(doc.past_from_date, getdate("2026-05-15"))
self.assertEqual(doc.past_to_date, getdate("2026-06-14"))
def test_weekly_window_is_the_previous_monday_to_sunday(self):
from_date, to_date = self.make_digest("Weekly").get_from_to_date()
self.assertEqual(from_date.weekday(), 0) # Monday
self.assertEqual((to_date - from_date).days, 6) # through Sunday
self.assertLess(to_date, now_datetime().date()) # entirely in the past

View File

@@ -432,7 +432,7 @@ def deactivate_sales_person(status: str, employee: str):
frappe.db.set_value("Sales Person", sales_person, "enabled", 0)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_user(employee: str, email: str | None = None, create_user_permission: int = 0) -> str:
emp = frappe.get_doc("Employee", employee)
emp.check_permission("write")

View File

@@ -8,7 +8,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "setting",
"icon": "sliders-horizontal",
"idx": 0,
"is_hidden": 0,
"label": "ERPNext Settings",
@@ -69,7 +69,7 @@
"type": "Link"
}
],
"modified": "2026-06-14 13:43:50.429297",
"modified": "2026-07-03 13:43:50.429297",
"modified_by": "Administrator",
"module": "Setup",
"name": "ERPNext Settings",
@@ -97,7 +97,7 @@
"type": "DocType"
},
{
"icon": "accounting",
"icon": "wallet",
"label": "Accounts Settings",
"link_to": "Accounts Settings",
"type": "DocType"
@@ -110,19 +110,19 @@
"type": "DocType"
},
{
"icon": "stock",
"icon": "package",
"label": "Stock Settings",
"link_to": "Stock Settings",
"type": "DocType"
},
{
"icon": "sell",
"icon": "store",
"label": "Selling Settings",
"link_to": "Selling Settings",
"type": "DocType"
},
{
"icon": "buying",
"icon": "shopping-cart",
"label": "Buying Settings",
"link_to": "Buying Settings",
"type": "DocType"
@@ -158,7 +158,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "accounting",
"icon": "wallet",
"indent": 0,
"keep_closed": 0,
"label": "Accounts Settings",
@@ -184,7 +184,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "sell",
"icon": "store",
"indent": 0,
"keep_closed": 0,
"label": "Selling Settings",
@@ -197,7 +197,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "buying",
"icon": "shopping-cart",
"indent": 0,
"keep_closed": 0,
"label": "Buying Settings",
@@ -210,7 +210,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "stock",
"icon": "package",
"indent": 0,
"keep_closed": 0,
"label": "Stock Settings",
@@ -236,7 +236,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "projects",
"icon": "folder-kanban",
"indent": 0,
"keep_closed": 0,
"label": "Projects Settings",
@@ -249,7 +249,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "crm",
"icon": "handshake",
"indent": 0,
"keep_closed": 0,
"label": "CRM Settings",
@@ -262,7 +262,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "support",
"icon": "headset",
"indent": 0,
"keep_closed": 0,
"label": "Support Settings",
@@ -275,7 +275,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "getting-started",
"icon": "rocket",
"indent": 1,
"keep_closed": 1,
"label": "Other Settings",

View File

@@ -8,7 +8,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "home",
"icon": "house",
"idx": 0,
"is_hidden": 0,
"label": "Home",
@@ -452,7 +452,7 @@
"type": "Link"
}
],
"modified": "2026-07-01 14:22:16.927245",
"modified": "2026-07-03 14:22:16.927245",
"modified_by": "Administrator",
"module": "Setup",
"name": "Home",

View File

@@ -79,14 +79,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "organization",
"icon": "building-2",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Organization",
"link_type": "DocType",
"links": [],
"modified": "2026-06-16 00:45:57.595188",
"modified": "2026-07-03 00:45:57.595188",
"modified_by": "Administrator",
"module": "Setup",
"module_onboarding": "Organization Onboarding",
@@ -103,7 +103,7 @@
"child": 0,
"collapsible": 1,
"default_workspace": 1,
"icon": "organization",
"icon": "building-2",
"indent": 0,
"keep_closed": 0,
"label": "Company",

View File

@@ -301,7 +301,7 @@ def get_batches_by_oldest(item_code: str, warehouse: str):
return batches_dates
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def split_batch(batch_no: str, item_code: str, warehouse: str, qty: float, new_batch_id: str | None = None):
"""Split the batch into a new batch"""
batch = frappe.get_doc(doctype="Batch", item=item_code, batch_id=new_batch_id).insert()

View File

@@ -475,7 +475,7 @@ function render_serial_batch_banner(wrapper) {
let banner_html = `
<div class="custom-serial-batch-banner ${hiddenClass}">
<div class="banner-content">
<span class="banner-icon">${frappe.utils.icon("solid-warning", "lg", "", "padding-bottom:2px")}</span>
<span class="banner-icon">${frappe.utils.icon("triangle-alert", "lg", "", "padding-bottom:2px")}</span>
<span class="banner-text">
${__("To use Serial / Batch feature, enable {0} in {1}.", [
`<b>${__("Activate Serial / Batch No for Item")}</b>`,

View File

@@ -73,7 +73,7 @@
"label": "Revaluation"
},
{
"description": "Stock Reconciliation auto-created to revalue on-hand stock to the new standard rate.",
"description": "Stock Reconciliation that revalues on-hand stock to this standard rate: auto-created when the rate is changed here, or the reconciliation that captured this rate (opening entry or rate change).",
"fieldname": "revaluation_entry",
"fieldtype": "Link",
"label": "Revaluation Entry",
@@ -95,7 +95,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-06-26 11:00:00.000000",
"modified": "2026-07-02 11:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item Standard Cost",

View File

@@ -91,13 +91,75 @@ class ItemStandardCost(Document):
# previous (or missing) rate earlier in the request, so the revaluation below — and anything
# else in this request — reads the newly submitted rate.
clear_item_standard_rate_cache()
# When a Stock Reconciliation captured this rate (opening entry or rate change), it has set
# revaluation_entry to itself and performs the revaluation. Don't spawn another one.
if self.revaluation_entry:
return
self.create_revaluation_entry()
def before_cancel(self):
frappe.throw(
_("Item Standard Cost cannot be cancelled. Submit a new record to change the standard rate.")
self.validate_no_stock_activity_on_or_after_effective_date()
def has_stock_activity_on_or_after_effective_date(self):
"""Is there any live stock transaction for this item on or after the effective date, other than
the revaluation Stock Reconciliation this record created? Such transactions are valued at this
standard rate, so this record cannot be safely cancelled while they exist."""
sle = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sle)
.select(sle.name)
.where(
(sle.item_code == self.item_code)
& (sle.company == self.company)
& (sle.is_cancelled == 0)
# posting_datetime (indexed) is preferred over posting_date; get_datetime on the date gives
# the start of the effective date, so this matches everything on or after it.
& (sle.posting_datetime >= get_datetime(self.effective_date))
)
.limit(1)
)
# The revaluation reco this record created posts on the effective date; exclude it, it is
# reversed together with this document in on_cancel.
if self.revaluation_entry:
query = query.where(sle.voucher_no != self.revaluation_entry)
return bool(query.run())
def validate_no_stock_activity_on_or_after_effective_date(self):
"""A submitted Item Standard Cost can be cancelled only when no stock transaction exists for the
item on or after its effective date, other than the revaluation Stock Reconciliation it created.
Later transactions are valued at this standard rate, so cancelling it would corrupt their
valuation and force a repost."""
if self.has_stock_activity_on_or_after_effective_date():
frappe.throw(
_(
"Item Standard Cost cannot be cancelled because stock transactions exist for Item {0} on or after the Effective Date {1}. Cancel those transactions first."
).format(
get_link_to_form("Item", self.item_code),
frappe.bold(frappe.format(self.effective_date, "Date")),
)
)
def on_cancel(self):
# Drop the cached standard rate first: this record is now cancelled, so the revaluation reversal
# below (and anything else in this request) must re-read the previous effective rate, not this one.
clear_item_standard_rate_cache()
# Set when this cancellation was triggered by the source reconciliation itself (it is already
# cancelling); reversing revaluation_entry would loop back into that same reconciliation.
if self.flags.from_source_reconciliation:
return
# Reverse the revaluation this record created.
if self.revaluation_entry:
reco = frappe.get_doc("Stock Reconciliation", self.revaluation_entry)
if reco.docstatus == 1:
reco.flags.via_item_standard_cost = True
reco.cancel()
def create_revaluation_entry(self):
"""Revalue on-hand stock to the new standard rate via a Stock Reconciliation.
@@ -230,6 +292,18 @@ def get_item_standard_rate(item_code, company, posting_date=None):
return flt(rate[0]) if rate else None
def has_item_standard_cost(item_code, company):
"""True if a submitted Item Standard Cost exists for the item in the company (any effective date).
Used to tell an opening Stock Reconciliation (no standard rate yet, rate is editable) apart from a
later one (standard rate owned by Item Standard Cost, only quantity may be adjusted)."""
return bool(
frappe.db.exists(
"Item Standard Cost",
{"item_code": item_code, "company": company, "docstatus": 1},
)
)
def clear_item_standard_rate_cache():
"""Drop the request-cached results of `get_item_standard_rate` so reads after a new Item Standard
Cost is submitted see the fresh rate instead of a value cached earlier in the same request."""

View File

@@ -232,29 +232,252 @@ class TestItemStandardCost(ERPNextTestSuite):
self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": se0.name}))
def test_cannot_cancel(self):
def test_cancel_allowed_without_stock_activity(self):
# No stock transaction on/after the effective date -> the standard cost can be cancelled.
item = create_standard_cost_item()
isc = create_item_standard_cost(item.name, rate=100)
isc.cancel()
self.assertEqual(isc.docstatus, 2)
def test_cancel_blocked_with_stock_activity(self):
# A stock transaction on/after the effective date is valued at this standard rate, so the
# standard cost cannot be cancelled while it exists.
item = create_standard_cost_item()
isc = create_item_standard_cost(item.name, rate=100)
make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=5, basic_rate=100)
self.assertRaises(frappe.ValidationError, isc.cancel)
def test_direct_stock_reconciliation_blocked(self):
def test_cancel_reverses_revaluation(self):
# Cancelling a rate change reverses the revaluation Stock Reconciliation it created, restoring
# the previous stock value (the movement that triggered it predates the effective date).
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=100, effective_date=add_days(today(), -10))
make_stock_entry(
item_code=item.name,
target=TEST_WAREHOUSE,
qty=10,
basic_rate=100,
posting_date=add_days(today(), -5),
)
isc2 = create_item_standard_cost(item.name, rate=130, effective_date=today())
self.assertTrue(isc2.revaluation_entry)
def stock_value():
return flt(
frappe.db.get_value(
"Bin", {"item_code": item.name, "warehouse": TEST_WAREHOUSE}, "stock_value"
)
)
self.assertEqual(stock_value(), 1300)
isc2.cancel()
self.assertEqual(frappe.db.get_value("Stock Reconciliation", isc2.revaluation_entry, "docstatus"), 2)
self.assertEqual(stock_value(), 1000)
def test_stock_reconciliation_rate_change_creates_standard_cost(self):
# Editing the rate on a reconciliation creates a new Item Standard Cost and revalues on-hand
# stock to it - the reconciliation is a shortcut into the standard cost, not a manual override.
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=100)
make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=10, basic_rate=100)
create_item_standard_cost(
item.name, rate=100, company=PI_COMPANY, effective_date=add_days(today(), -10)
)
make_stock_entry(
item_code=item.name,
to_warehouse=PI_STORES,
company=PI_COMPANY,
qty=10,
basic_rate=100,
posting_date=add_days(today(), -5),
)
# Same quantity, new rate 130: a new standard cost is set and the 10 on-hand units revalue to 1300.
reco = create_stock_reconciliation(
item_code=item.name, warehouse=PI_STORES, qty=10, rate=130, company=PI_COMPANY
)
self.assertEqual(reco.docstatus, 1)
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 130)
stock_value = frappe.db.get_value(
"Bin", {"item_code": item.name, "warehouse": PI_STORES}, "stock_value"
)
self.assertEqual(flt(stock_value), 1300)
def test_stock_reconciliation_qty_change_allowed(self):
# A reconciliation may adjust the quantity of a Standard Cost item: stock stays valued at the
# standard rate and the value difference is booked to the Stock Adjustment account.
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=100, company=PI_COMPANY)
make_stock_entry(
item_code=item.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=100
)
# Count down to 8 at the standard rate: value 800, a 200 reduction against Stock Adjustment.
reco = create_stock_reconciliation(
item_code=item.name, warehouse=PI_STORES, qty=8, rate=100, company=PI_COMPANY
)
self.assertEqual(reco.docstatus, 1)
bin_data = frappe.db.get_value(
"Bin",
{"item_code": item.name, "warehouse": PI_STORES},
["actual_qty", "stock_value"],
as_dict=True,
)
self.assertEqual(flt(bin_data.actual_qty), 8)
self.assertEqual(flt(bin_data.stock_value), 800)
sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": reco.name, "is_cancelled": 0}, "valuation_rate"
)
self.assertEqual(flt(sle), 100)
stock_adjustment = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
booked = flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
(reco.name, stock_adjustment),
)[0][0]
)
self.assertEqual(booked, 200)
def test_opening_reconciliation_creates_standard_cost(self):
# With no Item Standard Cost yet, an opening Stock Reconciliation may set the rate; that rate is
# captured into an Item Standard Cost record so the resulting stock (and later transactions) are
# valued at it.
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_item_standard_rate,
has_item_standard_cost,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
self.assertFalse(has_item_standard_cost(item.name, PI_COMPANY))
reco = create_stock_reconciliation(
item_code=item.name, warehouse=PI_STORES, qty=5, rate=100, company=PI_COMPANY
)
self.assertEqual(reco.docstatus, 1)
# The opening rate is now the item's standard cost.
self.assertTrue(has_item_standard_cost(item.name, PI_COMPANY))
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 100)
bin_data = frappe.db.get_value(
"Bin",
{"item_code": item.name, "warehouse": PI_STORES},
["actual_qty", "stock_value"],
as_dict=True,
)
self.assertEqual(flt(bin_data.actual_qty), 5)
self.assertEqual(flt(bin_data.stock_value), 500)
def test_opening_reconciliation_requires_rate(self):
# An opening reconciliation for a Standard Cost item with no standard rate yet must carry a
# positive rate - there is nothing to value the stock at otherwise.
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
self.assertRaises(
frappe.ValidationError,
create_stock_reconciliation,
item_code=item.name,
warehouse=TEST_WAREHOUSE,
qty=8,
rate=120,
warehouse=PI_STORES,
qty=5,
rate=0,
company=PI_COMPANY,
)
def test_opening_reconciliation_points_standard_cost_to_itself(self):
# The captured Item Standard Cost records the reconciliation as its revaluation entry (the reco is
# the revaluation) instead of spawning a second one.
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
reco = create_stock_reconciliation(
item_code=item.name, warehouse=PI_STORES, qty=5, rate=100, company=PI_COMPANY
)
isc = frappe.db.get_value(
"Item Standard Cost",
{"item_code": item.name, "company": PI_COMPANY, "docstatus": 1},
"revaluation_entry",
)
self.assertEqual(isc, reco.name)
def test_opening_reconciliation_cancel_cancels_standard_cost(self):
# Cancelling an opening reconciliation removes the stock it created, so the Item Standard Cost it
# introduced is cancelled too.
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import has_item_standard_cost
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
reco = create_stock_reconciliation(
item_code=item.name, warehouse=PI_STORES, qty=5, rate=100, company=PI_COMPANY
)
isc_name = frappe.db.get_value(
"Item Standard Cost", {"item_code": item.name, "company": PI_COMPANY, "docstatus": 1}, "name"
)
reco.cancel()
self.assertEqual(frappe.db.get_value("Item Standard Cost", isc_name, "docstatus"), 2)
self.assertFalse(has_item_standard_cost(item.name, PI_COMPANY))
def test_rate_change_reconciliation_cancel_reverts_standard_cost(self):
# A rate-change reconciliation revalues on-hand stock only on/after its effective date, and that
# revaluation is reversed on cancel. The pre-existing stock sits before the effective date, so no
# live SLE is valued at the new rate once the reco's own entry is reversed. Cancelling therefore
# cancels the Item Standard Cost it created and the item falls back to the previous standard rate.
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
item = create_standard_cost_item()
create_item_standard_cost(
item.name, rate=100, company=PI_COMPANY, effective_date=add_days(today(), -10)
)
make_stock_entry(
item_code=item.name,
to_warehouse=PI_STORES,
company=PI_COMPANY,
qty=10,
basic_rate=100,
posting_date=add_days(today(), -5),
)
reco = create_stock_reconciliation(
item_code=item.name, warehouse=PI_STORES, qty=10, rate=130, company=PI_COMPANY
)
isc_name = frappe.db.get_value("Item Standard Cost", {"revaluation_entry": reco.name}, "name")
self.assertTrue(isc_name)
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 130)
reco.cancel()
# The revaluation is reverted, so the standard cost it created is cancelled and the rate reverts.
self.assertEqual(frappe.db.get_value("Item Standard Cost", isc_name, "docstatus"), 2)
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 100)
def test_backdated_transaction_blocked(self):
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=100, effective_date=today())
@@ -364,6 +587,40 @@ class TestItemStandardCost(ERPNextTestSuite):
# The additional cost is credited out of its source account (it flowed into the variance).
self.assertEqual(gl_net(additional_cost_account), -30)
def test_manufacturing_variance_no_stock_adjustment_entry(self):
# With an additional cost in the mix, the net-zero Stock Adjustment reclassification must not
# survive as a debit == credit entry: only the real accounts (variance, additional cost source,
# stock) should be booked.
ensure_mfg_variance_account(PI_COMPANY)
additional_cost_account = "Expenses Included In Valuation - TCP1"
stock_adjustment = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
rm = create_standard_cost_item()
fg = create_standard_cost_item()
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
create_item_standard_cost(fg.name, rate=200, company=PI_COMPANY)
make_stock_entry(item_code=rm.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=50)
se = frappe.new_doc("Stock Entry")
se.purpose = "Repack"
se.stock_entry_type = "Repack"
se.company = PI_COMPANY
se.append("items", {"item_code": rm.name, "s_warehouse": PI_STORES, "qty": 5})
se.append("items", {"item_code": fg.name, "t_warehouse": PI_FG, "qty": 1, "is_finished_item": 1})
se.append(
"additional_costs",
{"expense_account": additional_cost_account, "description": "Freight", "amount": 30},
)
se.insert()
se.submit()
# No Stock Adjustment entry at all - the difference is entirely the manufacturing variance.
self.assertFalse(
frappe.db.exists(
"GL Entry", {"voucher_no": se.name, "account": stock_adjustment, "is_cancelled": 0}
)
)
def test_manufacturing_variance_account_required(self):
# Without a Manufacturing Variance account, submitting a Standard Cost Manufacture/Repack must fail.
previous = frappe.get_cached_value("Company", PI_COMPANY, "default_manufacturing_variance_account")
@@ -484,43 +741,6 @@ class TestItemStandardCost(ERPNextTestSuite):
# The submit must have invalidated the cache, so this reads the freshly submitted rate.
self.assertEqual(flt(get_item_standard_rate(item.name, TEST_COMPANY)), 100)
def test_pr_stock_value_excludes_rejected_warehouse(self):
# Accepted and rejected stock for one receipt row share voucher_detail_no. The standard-cost
# SRBNB split must clear only the accepted warehouse's value, not accepted + rejected.
from erpnext.accounts.doctype.purchase_invoice.services.gl_composer import (
PurchaseInvoiceGLComposer,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=100, company=PI_COMPANY)
rejected_warehouse = create_warehouse("_Test SC Rejected Warehouse", company=PI_COMPANY)
# Receive 10 accepted + 2 rejected at a billed rate of 150; both SLEs value at the standard 100.
pr = make_purchase_receipt(
item_code=item.name,
company=PI_COMPANY,
warehouse=PI_STORES,
qty=10,
rejected_qty=2,
rejected_warehouse=rejected_warehouse,
rate=150,
)
# Method body uses only `item`, so it can be called unbound.
def pr_value(stock_qty):
mock_item = frappe._dict(
purchase_receipt=pr.name, pr_detail=pr.items[0].name, stock_qty=stock_qty
)
return flt(PurchaseInvoiceGLComposer.get_pr_stock_value(None, mock_item))
# Billing all 10: accepted only (10 * 100), not accepted + rejected (12 * 100).
self.assertEqual(pr_value(10), 1000)
# Billing only 4 of the 10 accepted units: pro-rated to the invoiced qty (4 * 100).
self.assertEqual(pr_value(4), 400)
def test_pr_books_variance_to_ppv_account(self):
# Receiving a Standard Cost item at a rate above the standard must book the difference to the
# Purchase Price Variance account, not the default expense (COGS) account.
@@ -571,6 +791,88 @@ class TestItemStandardCost(ERPNextTestSuite):
frappe.db.set_value("Company", PI_COMPANY, "default_purchase_price_variance_account", previous)
frappe.clear_cache(doctype="Company")
def test_pi_without_update_stock_does_not_rebook_variance(self):
# The Purchase Receipt already booked the 70 variance to PPV. Billing it with a Purchase Invoice
# that has Update Stock disabled must only clear "Stock Received But Not Billed" at the full billed
# amount (200) - it must NOT re-book the variance to the Purchase Price Variance account.
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
ppv_account = ensure_ppv_account(PI_COMPANY)
srbnb_account = frappe.get_cached_value("Company", PI_COMPANY, "stock_received_but_not_billed")
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=130, company=PI_COMPANY)
pr = make_purchase_receipt(
item_code=item.name, company=PI_COMPANY, warehouse=PI_STORES, qty=1, rate=200
)
pi = make_purchase_invoice(pr.name)
self.assertEqual(pi.update_stock, 0)
pi.submit()
def booked(account):
return flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
(pi.name, account),
)[0][0]
)
# No variance re-booked on the invoice; SRBNB is fully cleared at the billed value.
self.assertEqual(booked(ppv_account), 0)
self.assertEqual(booked(srbnb_account), 200)
def test_material_receipt_books_variance_to_ppv(self):
# Receiving a Standard Cost item via Material Receipt at a manual basic rate (200) plus an
# additional cost (100) must value stock at the standard 130 and book the rest to the Purchase
# Price Variance account: (200*10 + 100) - 130*10 = 800.
ppv_account = ensure_ppv_account(PI_COMPANY)
additional_cost_account = "Expenses Included In Valuation - TCP1"
item = create_standard_cost_item()
create_item_standard_cost(item.name, rate=130, company=PI_COMPANY)
se = frappe.new_doc("Stock Entry")
se.purpose = "Material Receipt"
se.stock_entry_type = "Material Receipt"
se.company = PI_COMPANY
se.append(
"items",
{
"item_code": item.name,
"t_warehouse": PI_STORES,
"qty": 10,
"basic_rate": 200,
},
)
se.append(
"additional_costs",
{"expense_account": additional_cost_account, "description": "Freight", "amount": 100},
)
se.insert()
se.submit()
# Stock is valued at the standard rate, not the manual 200 + additional cost.
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name, "is_cancelled": 0},
["valuation_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(sle.valuation_rate), 130)
self.assertEqual(flt(sle.stock_value_difference), 1300)
def booked(account):
return flt(
frappe.db.sql(
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
(se.name, account),
)[0][0]
)
self.assertEqual(booked(ppv_account), 800)
def test_revaluation_posted_after_same_day_movement(self):
# A movement earlier on the effective date must not end up after the revaluation, otherwise the
# reco would backdate the current quantity ahead of it.

View File

@@ -10,7 +10,7 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}:
</h4>
<ul>

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